No description
  • TypeScript 97.8%
  • CSS 1.5%
  • Dockerfile 0.5%
  • Shell 0.2%
Find a file
ai 5ee8c70e24
All checks were successful
CI / test-and-build (push) Successful in 6m9s
CI / publish-image (push) Successful in 3m10s
CI / deploy-production (push) Successful in 6m18s
CI / notify-discord (push) Successful in 28s
fix ci workflow yaml
2026-06-15 22:02:14 +02:00
.forgejo/workflows fix ci workflow yaml 2026-06-15 22:02:14 +02:00
app Fix weekly digest scheduling 2026-06-14 14:57:12 +02:00
components Implemented the feature pass 2026-05-25 12:16:54 +02:00
lib Fix weekly digest scheduling 2026-06-14 14:57:12 +02:00
prisma Implemented the feature pass 2026-05-25 12:16:54 +02:00
tests Fix weekly digest scheduling 2026-06-14 14:57:12 +02:00
.dockerignore Initial commit: PingPong match tracker (Next.js + PostgreSQL) 2026-05-15 16:37:05 +02:00
.env.example Fix weekly digest scheduling 2026-06-14 14:57:12 +02:00
.gitignore Track season reset configuration 2026-06-09 01:11:12 +02:00
compose.yml Fix weekly digest scheduling 2026-06-14 14:57:12 +02:00
Dockerfile Initial commit: PingPong match tracker (Next.js + PostgreSQL) 2026-05-15 16:37:05 +02:00
entrypoint.sh fix: harden ratings, tournaments, and deployment safety 2026-05-25 11:39:19 +02:00
next-env.d.ts Initial commit: PingPong match tracker (Next.js + PostgreSQL) 2026-05-15 16:37:05 +02:00
next.config.ts Initial commit: PingPong match tracker (Next.js + PostgreSQL) 2026-05-15 16:37:05 +02:00
package-lock.json Initial commit: PingPong match tracker (Next.js + PostgreSQL) 2026-05-15 16:37:05 +02:00
package.json Fix weekly digest scheduling 2026-06-14 14:57:12 +02:00
plan.md Initial commit: PingPong match tracker (Next.js + PostgreSQL) 2026-05-15 16:37:05 +02:00
postcss.config.mjs Initial commit: PingPong match tracker (Next.js + PostgreSQL) 2026-05-15 16:37:05 +02:00
README.md Fix weekly digest scheduling 2026-06-14 14:57:12 +02:00
tailwind.config.ts Initial commit: PingPong match tracker (Next.js + PostgreSQL) 2026-05-15 16:37:05 +02:00
tsconfig.json Initial commit: PingPong match tracker (Next.js + PostgreSQL) 2026-05-15 16:37:05 +02:00
tsconfig.tsbuildinfo feat: tournament mode — single-elimination brackets 2026-05-15 22:10:52 +02:00

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 30003999 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, and PlayerAchievement. Correctness > speed (plan §7).
  • 16 achievement definitions across common → legendary. Two are hidden.
  • Engine at lib/achievements/engine.ts, rules in evaluateRules, static definitions in lib/achievements/definitions.ts. Add a new achievement by adding to the definitions list and one branch in evaluateRules.

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. See lib/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:

  1. Put the app behind HTTPS (reverse proxy with TLS).
  2. Set COOKIE_SECURE=true in .env so the session cookie requires https.
  3. Make sure your reverse proxy forwards the client IP as X-Forwarded-For (or X-Real-IP), otherwise the per-IP limit can't distinguish clients.
  4. Keep SESSION_SECRET strong 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 deploy when migration files exist. If none exist yet, it falls back to a non-destructive prisma 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-password if 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; the digest-cron compose service calls POST /api/cron/digest once 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 with Authorization: Bearer $DIGEST_CRON_SECRET.
  • Static avatar serving. Avatars are streamed through an API route from the uploads volume. 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.example present
  • README documents startup
  • Seed data is shipped (6 players, 15 matches)