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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user