diff --git a/CLAUDE.md b/CLAUDE.md
index f1a413c..0f80184 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -63,6 +63,7 @@ Async SQLAlchemy 2.0, independent of Socket.IO (mirrors how the engine is kept c
- Handlers: auth (`register_account`, `confirm_account`, `login`), lobby (`create_game`, `register_player`, `leave_game`, `start_game`, `end_game`), reconnect (`reconnect_to_game` via per-game token, `rejoin_game` via account), play (`game_status`, `player_cards`, `add_guess`, `play_card`), history (`get_player_history`, `get_game_detail`). Creating/joining/playing requires a logged-in `accounts[sid]`.
- **`api/auth.py`** — TOTP auth (`pyotp`), passwordless. `register_account` issues a secret + `otpauth://` URI (frontend renders the QR); `confirm_account`/`login` verify the 6-digit code and return a session token stored on `Player.auth_token`; `player_by_token` resolves the token sent in the Socket.IO `auth` handshake on `connect`.
- **`api/history.py`** — persistence orchestration over `db/`, reading values from the engine (never mutating it): `record_game_started`, `record_completed_rounds` (also tracks `series`/`round` position and sets `ended_at`), `mark_game_ended`, `get_standings` (DB-backed standings so the score survives a restart), `get_player_history`, `get_game_detail`. **Restore**: `rebuild_core(series, round)` reconstructs a `Bridzik` to a position (cards re-dealt fresh — the only non-deterministic part); `restore_game_core` / `get_unfinished_games` drive restore-on-startup.
+- **`api/leaderboard.py`** — rebríčky (socket udalosť `get_leaderboard`, iba čítanie). Len z naplno dohraných hier (`FULL_GAME_ROUNDS` kôl): top 10 podľa najlepšej hry hráča osobitne pre hry len s ľuďmi a hry s aspoň jedným botom (konto `bot:*`; boti sa v rebríčku nezobrazujú) a rebríček hodín (`ended_at - created_at`, aj hry s botmi, len hry do `MAX_GAME_DURATION` = 3 h, takže hra cez polnoc sa počíta, obnovená po dlhej pauze nie). Pozor: `Game.created_at` zapisuje DB (`server_default=now()`) v jej časovej zóne (prod Postgres Europe/Bratislava), `ended_at` Python ako naivný UTC, preto `_created_at_utc` na PostgreSQL prevádza `created_at` na UTC. Každý rebríček vracia `top` aj `me` (poradie prihláseného hráča). `get_leaderboard` číta DB, `build_leaderboard` radí čisté dáta (testy v `tests/test_history.py`, trieda `LeaderboardCase`).
### Frontend — `frontend/`
diff --git a/api/__init__.py b/api/__init__.py
index bae606f..e3ae768 100644
--- a/api/__init__.py
+++ b/api/__init__.py
@@ -12,7 +12,7 @@ import socketio
from bridzik import Bridzik, BridzikException, Card
from db.db import init_db
-from api import auth as auth_module, history
+from api import auth as auth_module, history, leaderboard
from api import bots as bots_module
from api import stats as stats_module
from api.auth import AuthError, RegistrationIncomplete
@@ -1016,3 +1016,14 @@ async def get_game_detail(sid, gid):
if detail is None:
return await send_error(sid, "Hra neexistuje.")
await sio.emit("get_game_detail", detail, to=sid)
+
+
+# --- leaderboard (read-only) ----------------------------------------------
+
+@sio.on("get_leaderboard")
+async def get_leaderboard(sid, *args):
+ account = accounts.get(sid)
+ if account is None:
+ return await send_error(sid, "Musíte byť prihlásený.")
+ data = await leaderboard.get_leaderboard(account["player_id"])
+ await sio.emit("get_leaderboard", data, to=sid)
diff --git a/api/leaderboard.py b/api/leaderboard.py
new file mode 100644
index 0000000..47ae1f2
--- /dev/null
+++ b/api/leaderboard.py
@@ -0,0 +1,153 @@
+"""Rebricky (leaderboard) nad `db/`.
+
+Tri rebricky, vsetky len z naplno dohranych hier (rovnako ako `get_player_history`):
+ - najviac bodov za jednu hru -- hry len s ludmi,
+ - najviac bodov za jednu hru -- hry, v ktorych hral aspon jeden bot,
+ - hodiny straveny v hrach (`ended_at - created_at`, aj hry s botmi).
+Boti sa v rebricku nikdy nezobrazuju. Pri bodoch ma kazdy hrac najviac jeden
+riadok (jeho najlepsia hra). Kazdy rebricek vrati `top` (TOP_N riadkov) a `me`
+(riadok prihlaseneho hraca s jeho poradim, alebo None ak v rebricku nie je).
+
+Citanie z DB (`get_leaderboard`) je oddelene od radenia (`build_leaderboard`),
+aby sa pravidla dali testovat bez DB.
+"""
+
+from datetime import timedelta
+
+from sqlalchemy import DateTime, cast, func, select
+
+from api.bots import is_bot_username
+from api.history import FULL_GAME_ROUNDS, _usernames_for
+from db.db import async_session, engine
+from db.models import Game, Guess
+
+TOP_N = 10
+
+# Do hodin sa pocita len hra, ktora trvala najviac tolko. Plynula hra cez polnoc
+# sa tym zapocita; obnovena hra dohravana o hodiny ci dni neskor (ended_at sa pri
+# obnove znovu nastavi) by do casu pridala celu pauzu, tak sa vynecha.
+MAX_GAME_DURATION = timedelta(hours=3)
+
+
+def _ranked(entries: list[dict], player_id: int) -> dict:
+ """Ocisluje uz zoradeny zoznam, vrati {top, me}."""
+ for i, entry in enumerate(entries):
+ entry["rank"] = i + 1
+ me = next((e for e in entries if e["player_id"] == player_id), None)
+ return {"top": entries[:TOP_N], "me": me}
+
+
+def _points_board(results, games, usernames, player_id: int, with_bots: bool) -> dict:
+ """Najlepsia hra kazdeho cloveka; `with_bots` vyberie hry s botom / bez bota."""
+ # vyssie body vyhrava; pri rovnosti skorsi koniec hry, potom player_id
+ rows = sorted(
+ (
+ (points, games[gid]["ended_at"], pid)
+ for gid, pid, points in results
+ if games[gid]["has_bot"] == with_bots
+ ),
+ key=lambda r: (-r[0], r[1], r[2]),
+ )
+ entries, seen = [], set()
+ for points, ended_at, pid in rows:
+ if pid in seen:
+ continue # hrac uz ma riadok so svojou najlepsou hrou
+ seen.add(pid)
+ entries.append(
+ {
+ "player_id": pid,
+ "username": usernames.get(pid, "?"),
+ "points": points,
+ "ended_at": ended_at.isoformat(),
+ }
+ )
+ return _ranked(entries, player_id)
+
+
+def _hours_board(results, games, usernames, player_id: int) -> dict:
+ """Sucet dlzky hier hraca (len hry do MAX_GAME_DURATION) a ich pocet."""
+ totals: dict[int, list[int]] = {} # player_id -> [sekundy, pocet hier]
+ for gid, pid, _points in results:
+ duration = games[gid]["ended_at"] - games[gid]["created_at"]
+ if duration < timedelta(0) or duration > MAX_GAME_DURATION:
+ continue
+ agg = totals.setdefault(pid, [0, 0])
+ agg[0] += int(duration.total_seconds())
+ agg[1] += 1
+
+ ordered = sorted(totals.items(), key=lambda kv: (-kv[1][0], kv[0]))
+ entries = [
+ {
+ "player_id": pid,
+ "username": usernames.get(pid, "?"),
+ "seconds": seconds,
+ "games": count,
+ }
+ for pid, (seconds, count) in ordered
+ ]
+ return _ranked(entries, player_id)
+
+
+def build_leaderboard(results, games, usernames, player_id: int) -> dict:
+ """Poskladaj vsetky rebricky.
+
+ `results` = zoznam (game_id, player_id, sucet bodov hraca v hre) len za dohrane
+ hry; `games[gid]` = {created_at, ended_at, has_bot}; `usernames` = player_id -> meno.
+ """
+ # boti hraju, ale v rebricku sa nezobrazuju
+ results = [r for r in results if not is_bot_username(usernames.get(r[1], ""))]
+ return {
+ "points": {
+ "humans": _points_board(results, games, usernames, player_id, False),
+ "bots": _points_board(results, games, usernames, player_id, True),
+ },
+ "hours": _hours_board(results, games, usernames, player_id),
+ }
+
+
+def _created_at_utc():
+ """`Game.created_at` ako naivny UTC, porovnatelny s `ended_at`.
+
+ `created_at` zapisuje DB (`server_default=func.now()`) v casovej zone DB
+ session -- v produkcii PostgreSQL s TZ Europe/Bratislava -- kym `ended_at`
+ zapisuje Python ako naivny UTC (history._utcnow_naive). Na PostgreSQL preto
+ cas prevedieme cez zonu session do UTC (spravne aj cez letny cas). SQLite
+ CURRENT_TIMESTAMP je uz UTC.
+ """
+ if engine.dialect.name == "postgresql":
+ return func.timezone("UTC", cast(Game.created_at, DateTime(timezone=True)))
+ return Game.created_at
+
+
+async def get_leaderboard(player_id: int) -> dict:
+ """Rebricky z DB + poradie hraca `player_id` (aj ked je mimo top)."""
+ created_at = _created_at_utc().label("created_at")
+ async with async_session() as session:
+ # Kazdy hrac ma jeden Guess na dohrate kolo (unikat v Guess), takze pocet
+ # riadkov (hra, hrac) = pocet dohratych kol -- rovnake pravidlo "dohrana
+ # hra" ako history._completed_rounds_per_game, bez druheho dotazu.
+ rows = (
+ await session.execute(
+ select(
+ Guess.game_id,
+ Guess.player_id,
+ func.sum(Guess.points),
+ created_at,
+ Game.ended_at,
+ )
+ .join(Game, Game.id == Guess.game_id)
+ .where(Game.ended_at.is_not(None))
+ .group_by(Guess.game_id, Guess.player_id, Game.created_at, Game.ended_at)
+ .having(func.count() >= FULL_GAME_ROUNDS)
+ )
+ ).all()
+ usernames = await _usernames_for(session, [r[1] for r in rows])
+
+ games: dict[str, dict] = {}
+ for gid, pid, _points, created, ended in rows:
+ game = games.setdefault(gid, {"created_at": created, "ended_at": ended, "has_bot": False})
+ # dohrana hra ma Guess pre vsetkych 4 hracov, takze vidime kazde sedadlo
+ if is_bot_username(usernames.get(pid, "")):
+ game["has_bot"] = True
+ results = [(gid, pid, int(points or 0)) for gid, pid, points, _c, _e in rows]
+ return build_leaderboard(results, games, usernames, player_id)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0eab3d0..fc1e711 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -9,6 +9,7 @@ import Lobby from './pages/Lobby';
import GameTable from './pages/GameTable';
import Auth from './pages/Auth';
import History from './pages/History';
+import Leaderboard from './pages/Leaderboard';
import Donate from './pages/Donate';
// Admin pulls in recharts — lazy-load it so players never download that chunk.
@@ -108,6 +109,7 @@ function AppInner() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/components/HeaderMenu.tsx b/frontend/src/components/HeaderMenu.tsx
index 45f2093..1773d62 100644
--- a/frontend/src/components/HeaderMenu.tsx
+++ b/frontend/src/components/HeaderMenu.tsx
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
interface Props {
onlineCount?: number;
availableCount?: number;
+ onLeaderboard: () => void;
onHistory: () => void;
onDonate: () => void;
onLogout: () => void;
@@ -12,6 +13,7 @@ interface Props {
export default function HeaderMenu({
onlineCount,
availableCount,
+ onLeaderboard,
onHistory,
onDonate,
onLogout,
@@ -50,7 +52,10 @@ export default function HeaderMenu({
)}
-