424 lines
20 KiB
HTML
424 lines
20 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Leaderboard · {{ league.name }} · myboker.org{% endblock %}
|
|
{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %}
|
|
{% block page_class %}page--app{% endblock %}
|
|
{% block content %}
|
|
|
|
<div class="page-header">
|
|
<div>
|
|
<h1 class="page-header__title">Leaderboard</h1>
|
|
<p class="page-header__sub">{{ session_count }} session{{ 's' if session_count != 1 else '' }}{% if selected_session_date %} through {{ selected_session_label }}{% endif %}</p>
|
|
</div>
|
|
</div>
|
|
<div class="leaderboard-hero-stats">
|
|
<div class="stat">
|
|
<span class="kicker">Cash paid out</span>
|
|
<div class="stat__val" style="color:var(--accent)">{{ cash_paid_out_cents | money }}</div>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="kicker">Sessions</span>
|
|
<div class="stat__val" style="color:var(--text-strong)">{{ session_count }}{% if total_session_count != session_count %}<span style="color:var(--faintest);font-size:16px"> / {{ total_session_count }}</span>{% endif %}</div>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="kicker">Players</span>
|
|
<div class="stat__val" style="color:var(--text-strong)">{{ all_count }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel stack" style="gap:0;margin-bottom:20px;">
|
|
<div class="panel__head">
|
|
<div>
|
|
<span class="kicker">Trend</span>
|
|
<h2 class="panel__title">Cumulative profit</h2>
|
|
<span class="panel__note" id="chartSeriesNote"></span>
|
|
</div>
|
|
<div style="display:flex;gap:6px;">
|
|
<button class="btn btn--ghost btn--sm" type="button" id="collapseLeaderboardChart">Collapse</button>
|
|
<button class="btn btn--ghost btn--sm" type="button" id="expandLeaderboardChart">Show all</button>
|
|
</div>
|
|
</div>
|
|
<div class="chart-wrap" id="leaderboardChartWrap">
|
|
<div class="chart-frame chart-frame--tall">
|
|
<canvas id="leaderboardChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toolbar">
|
|
<div class="toolbar__left">
|
|
<div class="segmented">
|
|
<button class="seg {{ 'is-on' if mode=='eligible' }}" data-mode="eligible">
|
|
Eligible <span class="seg__count">{{ eligible_count }}</span>
|
|
</button>
|
|
<button class="seg {{ 'is-on' if mode=='all' }}" data-mode="all">
|
|
All players <span class="seg__count">{{ all_count }}</span>
|
|
</button>
|
|
<button class="seg {{ 'is-on' if mode=='recent' }}" data-mode="recent">
|
|
Recent <span class="seg__count">5 sess</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="toolbar__right">
|
|
{% if seasons %}
|
|
<form class="session-filter-form" method="get" style="margin-right:8px;">
|
|
<input type="hidden" name="mode" value="{{ mode }}">
|
|
<select class="session-filter-select" name="season" onchange="this.form.submit()">
|
|
<option value="">All seasons</option>
|
|
{% for s in seasons %}
|
|
<option value="{{ s.id }}" {{ 'selected' if selected_season_id == s.id else '' }}>{{ s.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</form>
|
|
{% endif %}
|
|
<form class="session-filter-form" method="get">
|
|
<input type="hidden" name="mode" value="{{ mode }}">
|
|
{% if selected_season_id %}<input type="hidden" name="season" value="{{ selected_season_id }}">{% endif %}
|
|
<select class="session-filter-select" name="through_session" onchange="this.form.submit()">
|
|
<option value="">Latest session</option>
|
|
{% for s in available_sessions %}
|
|
<option value="{{ s.session_id }}" {{ 'selected' if selected_session_id == s.session_id else '' }}>{{ session_label(s) }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% if selected_session_id %}
|
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref, mode=mode) }}">Reset</a>
|
|
{% endif %}
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<p class="toolbar__hint">
|
|
{% if mode == 'eligible' %}
|
|
{{ eligible_min_sessions }}+ sessions required. Keeps one-off guests from skewing standings.
|
|
{% elif mode == 'all' %}
|
|
Everyone who's sat down — rankings can swing on tiny samples.
|
|
{% else %}
|
|
Form guide — aggregated over the last 5 sessions only.
|
|
{% endif %}
|
|
</p>
|
|
|
|
<div class="panel tbl-scroll" style="margin-bottom:20px;">
|
|
<table class="tbl" id="leaderboardTable" data-sort-key="net" data-sort-direction="desc">
|
|
<thead>
|
|
<tr>
|
|
<th class="col-rank" style="width:52px;">
|
|
<span style="font-family:var(--font-mono);font-size:10.5px;letter-spacing:.10em;text-transform:uppercase;color:var(--muted);">#</span>
|
|
</th>
|
|
<th class="col-player">
|
|
<button type="button" class="sort-btn" data-sort-key="player" data-sort-type="string" data-initial-direction="asc">Player <span class="sort-arrow">↕</span></button>
|
|
</th>
|
|
<th class="col-rank-delta"><button type="button" class="sort-btn" data-sort-key="rankdelta" data-sort-type="number" data-initial-direction="desc">Δ Rank <span class="sort-arrow">↕</span></button></th>
|
|
<th><button type="button" class="sort-btn" data-sort-key="net" data-sort-type="number" data-initial-direction="desc">Net <span class="sort-arrow">↓</span></button></th>
|
|
<th><button type="button" class="sort-btn" data-sort-key="winpct" data-sort-type="number" data-initial-direction="desc">Win% <span class="sort-arrow">↕</span></button></th>
|
|
<th><button type="button" class="sort-btn" data-sort-key="roi" data-sort-type="number" data-initial-direction="desc">ROI <span class="sort-arrow">↕</span></button></th>
|
|
<th><button type="button" class="sort-btn" data-sort-key="avgwin" data-sort-type="number" data-initial-direction="desc">Avg Win <span class="sort-arrow">↕</span></button></th>
|
|
<th><button type="button" class="sort-btn" data-sort-key="avgloss" data-sort-type="number" data-initial-direction="desc">Avg Loss <span class="sort-arrow">↕</span></button></th>
|
|
<th><button type="button" class="sort-btn" data-sort-key="sessions" data-sort-type="number" data-initial-direction="desc">Sessions <span class="sort-arrow">↕</span></button></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for player in main_board %}
|
|
{% set idx = loop.index %}
|
|
{% set player_href = url_for('leagues.player_detail', league_ref=league.url_ref, player_id=player_ids[player.player_name]) if player_ids.get(player.player_name) else '' %}
|
|
<tr data-player-row="true" data-original-rank="{{ idx }}"
|
|
{% if player_href %}onclick="window.location='{{ player_href }}'"{% endif %}>
|
|
<td class="col-rank" data-sort-value="{{ idx }}">
|
|
<span class="rank-num {{ 'rank-1' if idx==1 else 'rank-2' if idx==2 else 'rank-3' if idx==3 else '' }}" data-rank-cell>#{{ idx }}</span>
|
|
</td>
|
|
<td class="col-player" data-sort-value="{{ player.player_name|lower }}">
|
|
{% if player_href %}<a href="{{ player_href }}">{{ player.player_name }}</a>{% else %}{{ player.player_name }}{% endif %}
|
|
</td>
|
|
<td class="col-rank-delta" data-sort-value="{{ player.rank_change }}">
|
|
{% if player.rank_change > 0 %}
|
|
<span class="rank-delta rank-delta--up">▲{{ player.rank_change }}</span>
|
|
{% elif player.rank_change < 0 %}
|
|
<span class="rank-delta rank-delta--down">▼{{ player.rank_change | abs }}</span>
|
|
{% else %}
|
|
<span class="rank-delta rank-delta--flat">—</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="num-net {{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}"
|
|
data-sort-value="{{ player.total_net_cents }}">
|
|
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
|
|
</td>
|
|
<td data-sort-value="{{ '%.4f'|format(player.win_pct) }}">{{ "%.1f"|format(player.win_pct) }}%</td>
|
|
<td class="{{ 'num-pos' if player.roi_pct > 0 else 'num-neg' if player.roi_pct < 0 else '' }}"
|
|
data-sort-value="{{ '%.4f'|format(player.roi_pct) }}">{{ "%.1f"|format(player.roi_pct) }}%</td>
|
|
<td class="num-pos" data-sort-value="{{ player.avg_win_cents }}">{{ player.avg_win_cents | money }}</td>
|
|
<td class="num-neg" data-sort-value="{{ player.avg_loss_cents }}">{{ player.avg_loss_cents | money }}</td>
|
|
<td data-sort-value="{{ player.sessions_played }}">{{ player.sessions_played }}</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr>
|
|
<td colspan="9" style="text-align:left;padding:28px;color:var(--muted);">
|
|
No players yet — add the first event from a league session ledger.
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{% if mode == 'eligible' and provisional_board %}
|
|
<div style="margin-bottom:20px;">
|
|
<div class="toolbar" style="margin-top:0;margin-bottom:12px;">
|
|
<span class="kicker" style="margin:0;">Provisional · fewer than {{ eligible_min_sessions }} sessions</span>
|
|
</div>
|
|
<div class="provisional-grid">
|
|
{% for player in provisional_board %}
|
|
{% set player_href = url_for('leagues.player_detail', league_ref=league.url_ref, player_id=player_ids[player.player_name]) if player_ids.get(player.player_name) else '' %}
|
|
<a class="provisional-card" href="{{ player_href or '#' }}"
|
|
style="display:block;text-decoration:none;">
|
|
<p class="provisional-card__name">{{ player.player_name }}</p>
|
|
<div class="provisional-card__meta">
|
|
<span>{{ player.sessions_played }} sess</span>
|
|
<span class="{{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else '' }}"
|
|
style="font-family:var(--font-mono);font-size:11px;font-weight:600;">{{ player.total_net_cents | money }}</span>
|
|
</div>
|
|
</a>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Expand modal -->
|
|
<div id="chartModal" style="display:none;position:fixed;inset:0;z-index:80;align-items:center;justify-content:center;">
|
|
<div style="position:absolute;inset:0;background:rgba(8,10,12,.82);backdrop-filter:blur(6px);" data-close-modal></div>
|
|
<div style="position:relative;z-index:1;width:min(1280px,calc(100vw - 48px));margin:24px auto;padding:22px;background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--r-lg);box-shadow:0 24px 64px rgba(0,0,0,.4);">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:18px;">
|
|
<h2 style="margin:0;">Cumulative profit</h2>
|
|
<button class="btn btn--ghost btn--sm" data-close-modal>Close</button>
|
|
</div>
|
|
<div style="height:min(70vh,780px);position:relative;">
|
|
<canvas id="leaderboardChartExpanded"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const CHART_PALETTE = ['#9B8FE3','#35C6B8','#5AA2F5','#E0B85C','#58D18E','#F06F7A','#B0A6EF','#8FA4BB','#C99BE0','#6FB2C9'];
|
|
const DEFAULT_SERIES = 6;
|
|
const leaderboardChartData = {{ chart_data | tojson }};
|
|
const rootStyles = getComputedStyle(document.documentElement);
|
|
const textMuted = rootStyles.getPropertyValue('--muted').trim() || '#9AA5B4';
|
|
const gridColor = 'rgba(255,255,255,0.05)';
|
|
const axisColor = 'rgba(255,255,255,0.10)';
|
|
|
|
// Order players by activity so the most relevant get the clearest colors,
|
|
// then default the chart to the most active players to keep it readable.
|
|
leaderboardChartData.datasets.sort((a, b) => (b.sessions_played || 0) - (a.sessions_played || 0));
|
|
leaderboardChartData.datasets.forEach((ds, i) => {
|
|
ds.borderColor = CHART_PALETTE[i % CHART_PALETTE.length];
|
|
ds.backgroundColor = CHART_PALETTE[i % CHART_PALETTE.length];
|
|
});
|
|
|
|
function buildChartConfig(data, compact) {
|
|
return {
|
|
type: 'line',
|
|
data: {
|
|
labels: data.labels,
|
|
datasets: data.datasets.map(ds => ({
|
|
...ds,
|
|
pointRadius: 0,
|
|
pointHoverRadius: 4,
|
|
borderWidth: 2,
|
|
tension: 0.35,
|
|
spanGaps: true,
|
|
fill: false,
|
|
})),
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: { mode: 'nearest', axis: 'x', intersect: false },
|
|
elements: { line: { capBezierPoints: true }, point: { hitRadius: 8 } },
|
|
plugins: {
|
|
legend: {
|
|
display: true,
|
|
position: 'bottom',
|
|
labels: { color: textMuted, usePointStyle: true, pointStyle: 'line', boxWidth: 18, boxHeight: 2, padding: 16, font: { size: 12 } },
|
|
},
|
|
tooltip: {
|
|
mode: 'nearest', axis: 'x', intersect: false,
|
|
backgroundColor: 'rgba(13,17,22,0.95)',
|
|
borderColor: 'rgba(255,255,255,0.12)', borderWidth: 1,
|
|
padding: 10, cornerRadius: 6, usePointStyle: true,
|
|
titleColor: '#F2F0ED', bodyColor: '#C7CED8',
|
|
callbacks: {
|
|
label(ctx) {
|
|
const v = ctx.parsed.y;
|
|
return v == null ? `${ctx.dataset.label}: —` : `${ctx.dataset.label}: $${Number(v).toFixed(2)}`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
ticks: { color: textMuted, maxRotation: 0, autoSkip: true, maxTicksLimit: compact ? 6 : 10, font: { size: 11 } },
|
|
grid: { display: false },
|
|
border: { color: axisColor },
|
|
},
|
|
y: {
|
|
ticks: {
|
|
color: textMuted,
|
|
maxTicksLimit: compact ? 6 : 9,
|
|
font: { size: 11 },
|
|
callback(v) { return (v < 0 ? '-' : '') + '$' + Math.abs(Number(v)).toFixed(0); }
|
|
},
|
|
grid: { color: gridColor, drawTicks: false },
|
|
border: { display: false },
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
const ctx = document.getElementById('leaderboardChart');
|
|
let mainChart = null;
|
|
if (ctx && leaderboardChartData.labels.length > 0) {
|
|
const chartData = {
|
|
labels: leaderboardChartData.labels,
|
|
datasets: leaderboardChartData.datasets.slice(0, DEFAULT_SERIES),
|
|
};
|
|
mainChart = new Chart(ctx, buildChartConfig(chartData, true));
|
|
|
|
const note = document.getElementById('chartSeriesNote');
|
|
if (note) {
|
|
const total = leaderboardChartData.datasets.length;
|
|
const shown = chartData.datasets.length;
|
|
note.textContent = total > shown
|
|
? `Showing ${shown} most active players of ${total} — use Show all`
|
|
: `${total} player${total === 1 ? '' : 's'}`;
|
|
}
|
|
}
|
|
|
|
const modal = document.getElementById('chartModal');
|
|
const expandBtn = document.getElementById('expandLeaderboardChart');
|
|
let expandedChart = null;
|
|
|
|
expandBtn?.addEventListener('click', () => {
|
|
if (!leaderboardChartData.labels.length) return;
|
|
modal.style.display = 'flex';
|
|
document.body.style.overflow = 'hidden';
|
|
if (expandedChart) expandedChart.destroy();
|
|
expandedChart = new Chart(
|
|
document.getElementById('leaderboardChartExpanded'),
|
|
buildChartConfig(leaderboardChartData, false)
|
|
);
|
|
});
|
|
|
|
document.querySelectorAll('[data-close-modal]').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
modal.style.display = 'none';
|
|
document.body.style.overflow = '';
|
|
if (expandedChart) { expandedChart.destroy(); expandedChart = null; }
|
|
});
|
|
});
|
|
|
|
(function () {
|
|
const wrap = document.getElementById('leaderboardChartWrap');
|
|
const collapseBtn = document.getElementById('collapseLeaderboardChart');
|
|
const STORAGE_KEY = 'leaderboard_chart_collapsed';
|
|
let collapsed = localStorage.getItem(STORAGE_KEY) === '1';
|
|
|
|
function apply(animate) {
|
|
if (collapsed) {
|
|
if (animate) {
|
|
wrap.style.transition = 'max-height .25s ease, opacity .2s ease';
|
|
}
|
|
wrap.style.maxHeight = '0';
|
|
wrap.style.overflow = 'hidden';
|
|
wrap.style.opacity = '0';
|
|
collapseBtn.textContent = 'Expand chart';
|
|
} else {
|
|
wrap.style.transition = animate ? 'max-height .3s ease, opacity .25s ease' : '';
|
|
wrap.style.maxHeight = '600px';
|
|
wrap.style.overflow = '';
|
|
wrap.style.opacity = '1';
|
|
collapseBtn.textContent = 'Collapse';
|
|
}
|
|
}
|
|
|
|
apply(false);
|
|
|
|
collapseBtn.addEventListener('click', () => {
|
|
collapsed = !collapsed;
|
|
localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0');
|
|
apply(true);
|
|
});
|
|
})();
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape' && modal.style.display === 'flex') {
|
|
modal.style.display = 'none';
|
|
document.body.style.overflow = '';
|
|
if (expandedChart) { expandedChart.destroy(); expandedChart = null; }
|
|
}
|
|
});
|
|
|
|
// Segmented mode switcher
|
|
document.querySelectorAll('[data-mode]').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set('mode', btn.dataset.mode);
|
|
window.location.href = url.toString();
|
|
});
|
|
});
|
|
|
|
// Table sort
|
|
const tbl = document.getElementById('leaderboardTable');
|
|
const sortBtns = tbl ? Array.from(tbl.querySelectorAll('.sort-btn[data-sort-key]')) : [];
|
|
const colMap = { rank: 0, player: 1, rankdelta: 2, net: 3, winpct: 4, roi: 5, avgwin: 6, avgloss: 7, sessions: 8 };
|
|
|
|
function setActiveSort(key, dir) {
|
|
sortBtns.forEach(b => {
|
|
const active = b.dataset.sortKey === key;
|
|
b.classList.toggle('is-active', active);
|
|
const arrow = b.querySelector('.sort-arrow');
|
|
if (arrow) arrow.textContent = active ? (dir === 'asc' ? '↑' : '↓') : '↕';
|
|
});
|
|
}
|
|
|
|
function sortTable(key, sortType, forcedDir) {
|
|
if (!tbl) return;
|
|
const tbody = tbl.tBodies[0];
|
|
const rows = Array.from(tbody.querySelectorAll('tr[data-player-row]'));
|
|
const curKey = tbl.dataset.sortKey;
|
|
const curDir = tbl.dataset.sortDirection || 'desc';
|
|
let dir = forcedDir;
|
|
if (!dir) {
|
|
if (curKey === key) dir = curDir === 'desc' ? 'asc' : 'desc';
|
|
else {
|
|
const btn = sortBtns.find(b => b.dataset.sortKey === key);
|
|
dir = btn?.dataset.initialDirection || 'desc';
|
|
}
|
|
}
|
|
const mult = dir === 'asc' ? 1 : -1;
|
|
const ci = colMap[key] ?? 0;
|
|
rows.sort((a, b) => {
|
|
const av = a.children[ci]?.dataset.sortValue ?? '';
|
|
const bv = b.children[ci]?.dataset.sortValue ?? '';
|
|
if (sortType === 'number') {
|
|
const n = (Number(av) - Number(bv)) * mult;
|
|
if (n !== 0) return n;
|
|
} else {
|
|
const s = av.localeCompare(bv) * mult;
|
|
if (s !== 0) return s;
|
|
}
|
|
return Number(a.dataset.originalRank) - Number(b.dataset.originalRank);
|
|
});
|
|
rows.forEach(r => tbody.appendChild(r));
|
|
tbl.dataset.sortKey = key;
|
|
tbl.dataset.sortDirection = dir;
|
|
rows.forEach((row, i) => {
|
|
const rc = row.querySelector('[data-rank-cell]');
|
|
if (rc) rc.textContent = `#${i + 1}`;
|
|
});
|
|
setActiveSort(key, dir);
|
|
}
|
|
|
|
sortBtns.forEach(b => {
|
|
b.addEventListener('click', () => sortTable(b.dataset.sortKey, b.dataset.sortType));
|
|
});
|
|
setActiveSort('net', 'desc');
|
|
</script>
|
|
{% endblock %}
|