- TypeScript 97.8%
- CSS 1.5%
- Dockerfile 0.5%
- Shell 0.2%
| .forgejo/workflows | ||
| app | ||
| components | ||
| lib | ||
| prisma | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| compose.yml | ||
| Dockerfile | ||
| entrypoint.sh | ||
| next-env.d.ts | ||
| next.config.ts | ||
| package-lock.json | ||
| package.json | ||
| plan.md | ||
| postcss.config.mjs | ||
| README.md | ||
| tailwind.config.ts | ||
| tsconfig.json | ||
| tsconfig.tsbuildinfo | ||
PingPong — casual match tracker
A public ping-pong leaderboard for a small community. Admins log players and matches, the app handles Elo rating, achievements, and statistics.
Implemented from plan.md. All MVP features plus full achievements
and head-to-head (§3.1 + selected §3.2).
Stack
- Next.js 15 (App Router) + TypeScript + Tailwind CSS
- PostgreSQL 16 + Prisma ORM
- iron-session for admin auth
- Docker Compose for the whole thing
Quick start
cp .env.example .env # then edit SESSION_SECRET and passwords
docker compose up -d --build
Open http://localhost:3000.
Default admin credentials (from .env):
- email:
admin@pingpong.local - password:
admin123
Change them in .env before first boot, or after logging in from the admin
account page.
Ports
Plan §16.2 requires us to pick a free host port. When the project was scaffolded the following host ports were already in use on the target machine:
22, 53, 5678, 6556, 8010, 8035, 8080, 8085, 8888
Ports 3000–3999 were all free, so host port 3000 is the default
(APP_PORT in .env). The internal container port is always 3000.
Postgres is internal-only and not exposed on the host.
What's inside
Public (no login)
/— dashboard (recent matches, top 5, streak, latest achievements)/players— player grid/players/[id]— profile: Elo, record, rating history chart, achievements/matches— filterable match list/matches/[id]— single match with set-by-set scores + Elo deltas/rankings— leaderboard sorted by Elo/head-to-head— pick two players, see their rivalry/achievements— all achievements, recent unlocks, who has what
Admin (login required at /admin/login)
/admin— overview + audit log/admin/players— CRUD (create / edit / delete-or-archive)/admin/matches— CRUD with per-set scores and notes- Avatar upload (PNG / JPEG / WebP / GIF, max 2 MB) via multipart form
Rating & achievements
- Elo K-factor 24, initial rating 1000 (configurable per player).
- Any admin write on matches triggers a full chronological replay that
rebuilds
RatingHistory,Player.currentRating, andPlayerAchievement. Correctness > speed (plan §7). - 16 achievement definitions across
common → legendary. Two are hidden. - Engine at
lib/achievements/engine.ts, rules inevaluateRules, static definitions inlib/achievements/definitions.ts. Add a new achievement by adding to the definitions list and one branch inevaluateRules.
Project layout
app/ # Next.js routes
admin/ # admin shell
login/ # public login page
(authed)/ # route group, layout enforces auth
api/avatars/[filename]/ # serves uploaded avatars from the volume
players/ matches/ rankings/ achievements/ head-to-head/
components/ # shared React components
lib/
actions/ # "use server" — auth, players, matches
achievements/ # engine + definitions
auth.ts elo.ts stats.ts db.ts recalc.ts uploads.ts
prisma/
schema.prisma seed.ts
Dockerfile compose.yml entrypoint.sh .env.example
Data model
See prisma/schema.prisma. Matches plan §8: Player, Match, MatchSet,
AdminUser, RatingHistory, Achievement, PlayerAchievement, AuditLog.
Login security
- Passwords are hashed with bcrypt (cost 10).
- Sessions ride in an
httpOnly,sameSite=lax, signed cookie (iron-session). - Rate limiting on
/admin/login: 5 attempts per email / 15 min, and 20 attempts per IP / 15 min. In-memory, resets on container restart. Seelib/rate-limit.ts. - Server Actions are Origin-checked by Next.js (built-in CSRF protection).
- Error messages on failed login are intentionally generic (no user-enum).
For public deployment:
- Put the app behind HTTPS (reverse proxy with TLS).
- Set
COOKIE_SECURE=truein.envso the session cookie requires https. - Make sure your reverse proxy forwards the client IP as
X-Forwarded-For(orX-Real-IP), otherwise the per-IP limit can't distinguish clients. - Keep
SESSION_SECRETstrong and don't commit it.
Rotating the admin password (no UI for this in MVP):
docker compose exec \
-e ADMIN_EMAIL=you@example.com \
-e ADMIN_PASSWORD='new-password-here' \
app npm run admin:reset-password
The command updates an existing admin or creates a new one.
Trade-offs / simplifications
- Migrations optional during early iteration. Startup runs
prisma migrate deploywhen migration files exist. If none exist yet, it falls back to a non-destructiveprisma db push --skip-generate. Before running with real long-lived data, create and commit Prisma migrations. - Admin password reset script still available. Use
admin:reset-passwordif you cannot log in. The seed itself will never overwrite an existing admin's password. - Deleting a player with matches archives them instead of hard-deleting — deleting would corrupt match history + ratings. Archived players have an "Unarchive" button in the admin list.
- Email notifications and weekly digests. Match result emails are sent when
a player has an email and notifications enabled. Weekly digest scheduling
lives in
/admin/emails; thedigest-croncompose service callsPOST /api/cron/digestonce a minute and the app sends each active schedule once after its selected local time. If you run without compose, call the same endpoint from cron, optionally withAuthorization: Bearer $DIGEST_CRON_SECRET. - Static avatar serving. Avatars are streamed through an API route from
the
uploadsvolume. Simple and works with the standalone build — swap for a CDN later if traffic ever justifies it. - No image optimization /
next/image. Plain<img>tags with the avatar API route keep the setup trivial.
Useful commands
# Rebuild + restart
docker compose up -d --build
# Tail logs
docker compose logs -f app
# Open a psql shell inside the db container
docker compose exec db psql -U pingpong -d pingpong
# Re-run the seed (idempotent — won't overwrite existing players/matches)
docker compose exec app npx tsx prisma/seed.ts
# Force a full rating + achievement recalculation
docker compose exec app node -e "require('tsx/cjs').register(); require('./lib/recalc').recalculateAll().then(() => console.log('done'))"
# Drop everything (destroys db + uploads)
docker compose down -v
Running outside Docker (optional)
npm install
# Start postgres however you like; set DATABASE_URL to point at it.
npx prisma db push
npx tsx prisma/seed.ts
npm run dev # http://localhost:3000
Acceptance check (plan §20)
- Public web works without login
- Admin section needs login
- Admin can add a player
- Admin can add a match with sets
- Winner + Elo are computed on save
- Rankings update automatically
- Players have avatars
- Achievement engine present + 16 achievements defined
- Runs via
docker compose up -d --build - Free host port selected (3000) — no conflict with existing containers
.env.examplepresent- README documents startup
- Seed data is shipped (6 players, 15 matches)