pridanie rebricka (leaderboard)

Nova polozka menu Rebricek a stranka /leaderboard: top 10 podla najlepsej hry (hry len s ludmi / s aspon jednym botom) a podla hodin v hre (hry do 3 h). Backend api/leaderboard.py + socket udalost get_leaderboard, testy v tests/test_history.py.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YzEAACE9VgnLtXPuWGjwy
This commit is contained in:
timandClaude Sonnet 5.5 committed 2026-10-26 22:06:50 +01:00
1 parent 4000da0454
commit 04846aee26
11 files changed
+614 -12

No files matched your search

+2
View File
@@ -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() {
<Route path="/auth" element={<Auth />} />
<Route path="/" element={<GameList />} />
<Route path="/history" element={<History />} />
<Route path="/leaderboard" element={<Leaderboard />} />
<Route path="/donate" element={<Donate />} />
<Route path="/lobby/:gid" element={<Lobby />} />
<Route path="/game/:gid" element={<GameTable />} />
+13 -1
View File
@@ -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({
</span>
)}
<div className="hidden sm:flex items-center gap-3">
<div className="hidden sm:flex items-center gap-3 whitespace-nowrap">
<button onClick={onLeaderboard} className="text-gold hover:text-gold-bright">
Rebríček
</button>
<button onClick={onHistory} className="text-gold hover:text-gold-bright">
História
</button>
@@ -80,6 +85,13 @@ export default function HeaderMenu({
role="menu"
className="absolute right-0 top-11 z-40 w-44 bg-header border border-[#142018] rounded-xl py-1 shadow-[0_20px_60px_rgba(0,0,0,.6)]"
>
<button
role="menuitem"
onClick={pick(onLeaderboard)}
className="block w-full text-left px-4 py-2.5 text-sm text-gold hover:bg-gold/10"
>
Rebríček
</button>
<button
role="menuitem"
onClick={pick(onHistory)}
+7
View File
@@ -6,6 +6,7 @@ import type {
GameStatusPayload,
Hand,
HistoryGame,
Leaderboard,
MyPlayer,
OnlineCountPayload,
Registration,
@@ -35,6 +36,8 @@ export const emit = {
// history
getPlayerHistory: () => socket.emit('get_player_history'),
getGameDetail: (gid: string) => socket.emit('get_game_detail', gid),
// leaderboard
getLeaderboard: () => socket.emit('get_leaderboard'),
// lobby / game
createGame: (name: string) => {
_createName = name;
@@ -98,6 +101,10 @@ export function setupSocketListeners() {
useGameStore.getState().setGameDetail(detail);
});
socket.on('get_leaderboard', (data: Leaderboard) => {
useGameStore.getState().setLeaderboard(data);
});
socket.on('create_game', ({ gid }: { gid: string }) => {
// Auto-chain: register into the just-created game (identity comes from the session).
if (_createName !== null) {
+2 -1
View File
@@ -33,12 +33,13 @@ export default function GameList() {
};
return (
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
<div className="max-w-lg mx-auto p-4 pt-8 min-h-screen">
<div className="flex items-center justify-between mb-6">
<h1 className="font-serif text-2xl text-gold tracking-wide">Bridžik</h1>
<HeaderMenu
onlineCount={onlineCount}
availableCount={availableCount}
onLeaderboard={() => navigate('/leaderboard')}
onHistory={() => navigate('/history')}
onDonate={() => navigate('/donate')}
onLogout={handleLogout}
+214
View File
@@ -0,0 +1,214 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket';
import { displayName } from '../lib/names';
import type { Board, HoursEntry, PointsEntry } from '../types';
type Tab = 'points' | 'hours';
type Kind = 'humans' | 'bots';
/** `ended_at` is naive UTC from the server (no "Z") -- parse it as UTC so the
* date is the player's local day, not shifted near midnight. */
function fmtDate(iso: string): string {
const d = new Date(/(Z|[+-]\d\d:?\d\d)$/i.test(iso) ? iso : `${iso}Z`);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString('sk-SK');
}
function fmtDuration(seconds: number): string {
const totalMin = Math.floor(seconds / 60);
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return h > 0 ? `${h} h ${String(m).padStart(2, '0')} min` : `${m} min`;
}
interface RowProps {
rank: number;
name: string;
value: string;
unit?: string;
sub: string;
isMe: boolean;
}
function Row({ rank, name, value, unit, sub, isMe }: RowProps) {
const top = rank <= 3;
return (
<div
className={`grid grid-cols-[34px_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3.5 py-2 border-b border-gold/5 last:border-b-0 ${
isMe
? 'bg-gold/10 border-y border-gold/30'
: rank === 1
? 'bg-gradient-to-r from-gold/[.12] to-transparent'
: ''
}`}
>
<span
className={`font-serif text-center tabular-nums ${
isMe || top ? 'text-gold-bright' : 'text-[#7a7058]'
} ${top ? 'font-semibold' : ''}`}
>
{rank}.
</span>
<span className={`min-w-0 truncate font-medium ${isMe ? 'text-gold-bright' : 'text-green-score'}`}>
{name}
{isMe && (
<span className="ml-1.5 align-[2px] rounded bg-gold px-1 py-px text-[10px] uppercase tracking-wider text-table">
Ty
</span>
)}
</span>
<span className="text-right font-serif text-gold leading-tight whitespace-nowrap tabular-nums">
{value}
{unit && <small className="ml-0.5 font-sans text-[11px] text-green-dim">{unit}</small>}
<span className="block font-sans text-[11px] text-[#7a7058]">{sub}</span>
</span>
</div>
);
}
/** A ranked row without the "is this me" flag, which BoardView decides. */
type RowData = Omit<RowProps, 'isMe'> & { playerId: number };
/** Top 10 + (if the viewer is outside it) a gap and the viewer's own row. */
function BoardView({ top, me, myId }: { top: RowData[]; me: RowData | null; myId: number | undefined }) {
if (top.length === 0) {
return <p className="text-center text-green-dim py-6">Zatiaľ žiadne dohrané hry.</p>;
}
const meOutside = me && !top.some((r) => r.playerId === me.playerId);
return (
<div className="bg-header border border-[#142018] rounded-xl overflow-hidden">
{top.map((r) => (
<Row key={r.playerId} {...r} isMe={r.playerId === myId} />
))}
{meOutside && (
<>
<div aria-hidden className="text-center text-[13px] tracking-[.4em] text-[#7a7058] py-0.5 bg-gold/[.03]">
···
</div>
<Row {...me} isMe />
</>
)}
</div>
);
}
function toRows<T>(board: Board<T>, toRow: (e: T) => RowData) {
return { top: board.top.map(toRow), me: board.me && toRow(board.me) };
}
const pointsRow = (e: PointsEntry): RowData => ({
playerId: e.player_id,
rank: e.rank,
name: displayName(e.username),
value: String(e.points),
unit: 'b.',
sub: fmtDate(e.ended_at),
});
const hoursRow = (e: HoursEntry): RowData => ({
playerId: e.player_id,
rank: e.rank,
name: displayName(e.username),
value: fmtDuration(e.seconds),
sub: `${e.games} ${e.games === 1 ? 'hra' : e.games >= 2 && e.games <= 4 ? 'hry' : 'hier'}`,
});
const TABS: [Tab, string][] = [
['points', 'Najviac bodov'],
['hours', 'Hodiny v hre'],
];
const KINDS: [Kind, string][] = [
['humans', 'Len ľudia'],
['bots', 'S botmi'],
];
export default function Leaderboard() {
const navigate = useNavigate();
const leaderboard = useGameStore((s) => s.leaderboard);
const myId = useGameStore((s) => s.account?.player_id);
const setLeaderboard = useGameStore((s) => s.setLeaderboard);
const [tab, setTab] = useState<Tab>('points');
const [kind, setKind] = useState<Kind>('humans');
// Drop the snapshot on leave, so a revisit shows loading instead of stale ranks.
useEffect(() => {
emit.getLeaderboard();
return () => setLeaderboard(null);
}, [setLeaderboard]);
const rows = !leaderboard
? null
: tab === 'points'
? toRows(leaderboard.points[kind], pointsRow)
: toRows(leaderboard.hours, hoursRow);
return (
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
<div className="flex items-center justify-between mb-5">
<h1 className="font-serif text-2xl text-gold">Rebríček</h1>
<button onClick={() => navigate('/')} className="text-sm text-green-dim hover:text-gold">
Späť
</button>
</div>
<div role="tablist" className="grid grid-cols-2 gap-[3px] p-[3px] mb-4 rounded-xl border border-gold/20">
{TABS.map(([key, label]) => (
<button
key={key}
role="tab"
aria-selected={tab === key}
onClick={() => setTab(key)}
className={`py-1.5 rounded-lg text-sm transition-colors ${
tab === key ? 'bg-gold text-table font-serif font-semibold' : 'text-green-dim hover:text-gold'
}`}
>
{label}
</button>
))}
</div>
{tab === 'points' && (
<div role="group" aria-label="Typ hier" className="flex gap-2 mb-4">
{KINDS.map(([key, label]) => (
<button
key={key}
aria-pressed={kind === key}
onClick={() => setKind(key)}
className={`px-3.5 py-1 rounded-full border text-sm transition-colors ${
kind === key
? 'border-gold text-gold-bright bg-gold/10'
: 'border-gold/25 text-green-dim hover:border-gold/50 hover:text-gold'
}`}
>
{label}
</button>
))}
</div>
)}
<p className="text-xs text-green-dim mb-3">
{tab === 'hours'
? 'Celkový čas strávený v dohraných hrách, vrátane hier s botmi.'
: kind === 'humans'
? 'Najvyšší počet bodov v jednej hre. Počítajú sa len hry, ktoré hrali samí ľudia.'
: 'Najvyšší počet bodov v jednej hre, v ktorej hral aspoň jeden bot.'}
</p>
{rows ? (
<BoardView top={rows.top} me={rows.me} myId={myId} />
) : (
<p className="text-center text-green-dim pt-10 font-serif italic">Načítava sa…</p>
)}
{rows && rows.top.length > 0 && !rows.me && (
<p className="text-xs text-[#7a7058] mt-3 px-0.5">V tomto rebríčku zatiaľ nie si.</p>
)}
<p className="text-xs text-[#7a7058] mt-3 px-0.5">
{tab === 'points'
? 'Rebríček počíta len dohrané hry. Pri bodoch je dátum hry, v ktorej sa výsledok dosiahol.'
: 'Počíta sa čas od začiatku po koniec hry. Hry dlhšie ako 3 hodiny (napr. obnovené na druhý deň) sa nezapočítavajú.'}
</p>
</div>
);
}
+6
View File
@@ -6,6 +6,7 @@ import type {
GameStatusPayload,
Hand,
HistoryGame,
Leaderboard,
MyPlayer,
Registration,
} from '../types';
@@ -18,6 +19,7 @@ interface GameStore {
registration: Registration | null;
history: HistoryGame[];
gameDetail: GameDetail | null;
leaderboard: Leaderboard | null;
myPlayer: MyPlayer | null;
gameStatus: GameStatusPayload | null;
hand: Hand;
@@ -30,6 +32,7 @@ interface GameStore {
setRegistration: (registration: Registration | null) => void;
setHistory: (history: HistoryGame[]) => void;
setGameDetail: (detail: GameDetail | null) => void;
setLeaderboard: (leaderboard: Leaderboard | null) => void;
setMyPlayer: (player: MyPlayer | null) => void;
setGameStatus: (status: GameStatusPayload) => void;
setHand: (hand: Hand) => void;
@@ -48,6 +51,7 @@ export const useGameStore = create<GameStore>((set) => ({
registration: null,
history: [],
gameDetail: null,
leaderboard: null,
myPlayer: null,
gameStatus: null,
hand: {},
@@ -60,6 +64,7 @@ export const useGameStore = create<GameStore>((set) => ({
setRegistration: (registration) => set({ registration }),
setHistory: (history) => set({ history }),
setGameDetail: (gameDetail) => set({ gameDetail }),
setLeaderboard: (leaderboard) => set({ leaderboard }),
setMyPlayer: (myPlayer) => set({ myPlayer }),
setGameStatus: (gameStatus) => set({ gameStatus }),
setHand: (hand) => set({ hand }),
@@ -83,6 +88,7 @@ export const useGameStore = create<GameStore>((set) => ({
registration: null,
history: [],
gameDetail: null,
leaderboard: null,
myPlayer: null,
gameStatus: null,
hand: {},
+30
View File
@@ -75,6 +75,36 @@ export interface Registration {
otpauth_uri: string;
}
// --- leaderboard ---
export interface PointsEntry {
rank: number;
player_id: number;
username: string;
points: number;
/** Koniec hry, v ktorej sa výsledok dosiahol (ISO). */
ended_at: string;
}
export interface HoursEntry {
rank: number;
player_id: number;
username: string;
seconds: number;
games: number;
}
/** `top` = prvých 10, `me` = riadok prihláseného hráča (null, ak v rebríčku nie je). */
export interface Board<T> {
top: T[];
me: T | null;
}
export interface Leaderboard {
points: { humans: Board<PointsEntry>; bots: Board<PointsEntry> };
hours: Board<HoursEntry>;
}
// --- history ---
export interface HistoryGame {