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 -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>
);
}