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:
1 parent
4000da0454
commit
04846aee26
11 files changed
+614
-12
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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: {},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user