Files
boker/templates/leaderboard.html
T

597 lines
25 KiB
HTML

{% extends "base.html" %}
{% block title %}Leaderboard · Poker Portal{% endblock %}
{% block content %}
<section class="hero-card">
<div>
<p class="eyebrow">All-time results</p>
<h1>Leaderboard</h1>
<p class="muted-text">Track who is up, who is chasing, and how everyone has moved across {{ session_count }} recorded sessions{% if selected_session_date %} through {{ selected_session_label }}{% endif %}.</p>
</div>
</section>
<section class="grid two-col leaderboard-layout">
<div class="panel chart-panel chart-panel-large">
<div class="panel-header panel-header-with-actions">
<div>
<p class="eyebrow">Trend</p>
<h2>Cumulative profit over time</h2>
</div>
<div class="panel-actions">
<button class="ghost-button compact-button" type="button" id="resetLeaderboardZoom" hidden>Reset zoom</button>
<button class="secondary-button" type="button" id="expandLeaderboardChart">Expand chart</button>
</div>
</div>
<div class="chart-frame chart-frame-interactive" id="leaderboardChartFrame" role="button" tabindex="0" aria-label="Expand cumulative profit chart">
<canvas id="leaderboardChart"></canvas>
</div>
</div>
<div class="panel stat-panel">
<p class="eyebrow">Quick read</p>
<h2>Table snapshot</h2>
<div class="stat-list">
<div class="stat-row"><span>Players tracked</span><strong>{{ leaderboard|length }}</strong></div>
<div class="stat-row"><span>Sessions tracked</span><strong>{{ session_count }}{% if total_session_count != session_count %} / {{ total_session_count }}{% endif %}</strong></div>
<div class="stat-row"><span>Through session</span><strong>{{ selected_session_label }}</strong></div>
<div class="stat-row"><span>Best player</span><strong>{% if leaderboard %}{{ leaderboard[0].player_name }}{% else %}—{% endif %}</strong></div>
<div class="stat-row"><span>Worst player</span><strong>{% if leaderboard %}{{ leaderboard[-1].player_name }}{% else %}—{% endif %}</strong></div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-header leaderboard-header">
<div>
<p class="eyebrow">Standings</p>
<h2>All-time leaderboard</h2>
<p class="muted-text small">Cash in / paid out reflect real money movement. Net / ROI reflect poker results.</p>
</div>
<div class="leaderboard-controls">
<form method="get" class="session-filter-form">
<label for="through_session" class="session-filter-label">Through session</label>
<select id="through_session" name="through_session" class="session-filter-select" onchange="this.form.submit()">
<option value="" {% if not selected_session_id %}selected{% endif %}>Latest session</option>
{% for session in available_sessions %}
<option value="{{ session.session_id }}" {% if selected_session_id == session.session_id %}selected{% endif %}>{{ session_label(session) }}</option>
{% endfor %}
</select>
{% if selected_session_date %}
<a class="ghost-button compact-button" href="{{ url_for('leaderboard') }}">Reset</a>
{% endif %}
</form>
<p class="muted-text small">Click any column to sort.</p>
</div>
</div>
<div class="table-wrap">
<table id="leaderboardTable" class="sortable-table" data-sort-key="net" data-sort-direction="desc">
<thead>
<tr>
<th><button type="button" class="sort-button" data-sort-key="rank" data-sort-type="number" data-initial-direction="asc">Rank <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="rankdelta" data-sort-type="number" data-initial-direction="desc">Rank Δ <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="player" data-sort-type="string" data-initial-direction="asc">Player <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="cashin" data-sort-type="number" data-initial-direction="desc">Cash in <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="paidout" data-sort-type="number" data-initial-direction="desc">Paid out <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button is-active" data-sort-key="net" data-sort-type="number" data-initial-direction="desc">Net <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="winpct" data-sort-type="number" data-initial-direction="desc">Win % <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="roi" data-sort-type="number" data-initial-direction="desc">ROI <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="avgwin" data-sort-type="number" data-initial-direction="desc">Avg Win <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="avgloss" data-sort-type="number" data-initial-direction="desc">Avg Loss <span class="sort-indicator"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="sessions" data-sort-type="number" data-initial-direction="desc">Sessions <span class="sort-indicator"></span></button></th>
</tr>
</thead>
<tbody>
{% for player in leaderboard %}
<tr data-player-row="true" data-original-rank="{{ loop.index }}">
<td data-sort-value="{{ loop.index }}" data-rank-cell>#{{ loop.index }}</td>
<td data-sort-value="{{ player.rank_change }}">
{% if player.rank_change > 0 %}
<span class="rank-move rank-up">↑ {{ player.rank_change }}</span>
{% elif player.rank_change < 0 %}
<span class="rank-move rank-down">↓ {{ player.rank_change | abs }}</span>
{% else %}
<span class="rank-move rank-neutral">-</span>
{% endif %}
</td>
<td data-sort-value="{{ player.player_name|lower }}"><a href="{{ url_for('player_detail', player_name=player.player_name) }}">{{ player.player_name }}</a></td>
<td data-sort-value="{{ player.total_buy_in_cents }}">{{ player.total_buy_in_cents | money }}</td>
<td data-sort-value="{{ player.total_paid_cents }}">{{ player.total_paid_cents | money }}</td>
<td data-sort-value="{{ player.total_net_cents }}" class="{{ 'positive' if player.total_net_cents > 0 else 'negative' if player.total_net_cents < 0 else 'neutral' }}">{{ player.total_net_cents | money }}</td>
<td data-sort-value="{{ '%.4f'|format(player.win_pct) }}">{{ "%.1f"|format(player.win_pct) }}%</td>
<td data-sort-value="{{ '%.4f'|format(player.roi_pct) }}">{{ "%.1f"|format(player.roi_pct) }}%</td>
<td data-sort-value="{{ player.avg_win_cents }}">{{ player.avg_win_cents | money }}</td>
<td 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="11">No data yet. Add the first event from the admin page.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<div class="chart-modal" id="chartModal" aria-hidden="true">
<div class="chart-modal-backdrop" data-close-chart-modal></div>
<div class="chart-modal-card">
<div class="panel-header panel-header-with-actions">
<div>
<p class="eyebrow">Expanded view</p>
<h2>Cumulative profit over time</h2>
</div>
<div class="panel-actions">
<button class="secondary-button" type="button" id="fullscreenLeaderboardChart">Fullscreen</button>
<button class="ghost-button" type="button" data-close-chart-modal>Close</button>
</div>
</div>
<div class="chart-modal-frame" id="chartModalFrame">
<canvas id="leaderboardChartExpanded"></canvas>
</div>
</div>
</div>
<script>
const leaderboardChartData = {{ chart_data | tojson }};
const rootStyles = getComputedStyle(document.documentElement);
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
const lineColor = rootStyles.getPropertyValue('--line').trim();
function mainLeaderboardChartData(chartData) {
const repeatedPlayerDatasets = chartData.datasets.filter((dataset) => {
return Number(dataset.sessions_played || 0) > 1;
});
const visibleDatasets = repeatedPlayerDatasets.length > 0
? repeatedPlayerDatasets
: chartData.datasets;
return {
labels: [...chartData.labels],
datasets: visibleDatasets.map((dataset) => ({
...dataset,
data: [...dataset.data],
pointRadius: 0,
pointHoverRadius: 5,
borderWidth: 2.5,
tension: 0.28,
})),
};
}
function moneyTickLabel(value) {
const numericValue = Number(value);
const absoluteValue = Math.abs(numericValue);
const formattedValue = Number.isInteger(absoluteValue)
? absoluteValue.toFixed(0)
: absoluteValue.toFixed(2);
return `${numericValue < 0 ? '-' : ''}$${formattedValue}`;
}
function chartYScaleOptions(chartData, compact) {
const values = chartData.datasets
.flatMap((dataset) => dataset.data)
.filter((value) => value !== null && value !== undefined)
.map(Number);
if (values.length === 0) {
return {};
}
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const maxAbsValue = Math.max(Math.abs(minValue), Math.abs(maxValue));
const stepSize = maxAbsValue <= 75 ? 25 : undefined;
return {
suggestedMin: minValue < 0 ? minValue * 1.12 : 0,
suggestedMax: maxValue > 0 ? maxValue * 1.12 : 0,
ticks: {
color: textMuted,
maxTicksLimit: compact ? 8 : 10,
stepSize,
callback(value) {
return moneyTickLabel(value);
},
},
grid: { color: lineColor },
};
}
function buildLeaderboardChartConfig(chartData, options = {}) {
const compact = options.compact === true;
return {
type: 'line',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false },
elements: {
point: {
radius: compact ? 0 : 2,
hoverRadius: 5,
},
line: {
borderWidth: compact ? 2.5 : 2,
tension: 0.28,
},
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
color: textMuted,
usePointStyle: true,
boxWidth: compact ? 8 : 10,
boxHeight: compact ? 8 : 10,
padding: compact ? 12 : 18,
},
},
tooltip: {
callbacks: {
label(context) {
const value = context.parsed.y;
if (value === null || value === undefined) {
return `${context.dataset.label}: —`;
}
return `${context.dataset.label}: $${Number(value).toFixed(2)}`;
},
},
},
},
scales: {
x: {
ticks: {
color: textMuted,
maxRotation: 0,
autoSkip: true,
maxTicksLimit: compact ? 6 : 10,
},
grid: {
color: lineColor,
display: !compact,
},
},
y: chartYScaleOptions(chartData, compact),
},
},
};
}
const zoomSelectionPlugin = {
id: 'zoomSelection',
beforeDraw(chart) {
const drag = chart.$zoomDrag;
if (!drag || !drag.active || drag.startX === drag.endX) {
return;
}
const { ctx, chartArea } = chart;
const left = Math.max(chartArea.left, Math.min(drag.startX, drag.endX));
const right = Math.min(chartArea.right, Math.max(drag.startX, drag.endX));
ctx.save();
ctx.fillStyle = 'rgba(229, 139, 58, 0.14)';
ctx.strokeStyle = 'rgba(229, 139, 58, 0.74)';
ctx.lineWidth = 1;
ctx.fillRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top);
ctx.strokeRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top);
ctx.restore();
},
};
Chart.register(zoomSelectionPlugin);
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function enableDragZoom(chart, resetButton) {
if (!chart || !chart.canvas || !resetButton) {
return;
}
const canvas = chart.canvas;
const minDragDistance = 14;
let draggedToZoom = false;
function pointerX(event) {
const rect = canvas.getBoundingClientRect();
return event.clientX - rect.left;
}
function eventInChartArea(event) {
const x = pointerX(event);
const y = event.clientY - canvas.getBoundingClientRect().top;
const area = chart.chartArea;
return x >= area.left && x <= area.right && y >= area.top && y <= area.bottom;
}
canvas.addEventListener('pointerdown', (event) => {
if (!eventInChartArea(event)) {
return;
}
canvas.setPointerCapture(event.pointerId);
const x = pointerX(event);
chart.$zoomDrag = { active: true, startX: x, endX: x };
chart.update('none');
});
canvas.addEventListener('pointermove', (event) => {
if (!chart.$zoomDrag?.active) {
return;
}
chart.$zoomDrag.endX = pointerX(event);
chart.draw();
});
canvas.addEventListener('pointerup', (event) => {
const drag = chart.$zoomDrag;
if (!drag?.active) {
return;
}
drag.active = false;
const distance = Math.abs(drag.endX - drag.startX);
if (distance < minDragDistance) {
chart.draw();
return;
}
draggedToZoom = true;
const area = chart.chartArea;
const startPixel = clamp(Math.min(drag.startX, drag.endX), area.left, area.right);
const endPixel = clamp(Math.max(drag.startX, drag.endX), area.left, area.right);
const xScale = chart.scales.x;
const minIndex = clamp(Math.floor(xScale.getValueForPixel(startPixel)), 0, chart.data.labels.length - 1);
const maxIndex = clamp(Math.ceil(xScale.getValueForPixel(endPixel)), 0, chart.data.labels.length - 1);
if (maxIndex <= minIndex) {
chart.draw();
return;
}
xScale.options.min = minIndex;
xScale.options.max = maxIndex;
resetButton.hidden = false;
chart.update();
});
canvas.addEventListener('click', (event) => {
if (!draggedToZoom) {
return;
}
event.stopPropagation();
event.preventDefault();
draggedToZoom = false;
});
canvas.addEventListener('pointercancel', () => {
if (chart.$zoomDrag) {
chart.$zoomDrag.active = false;
chart.draw();
}
});
resetButton.addEventListener('click', () => {
delete chart.options.scales.x.min;
delete chart.options.scales.x.max;
resetButton.hidden = true;
chart.update();
});
}
function suppressLegendClickExpansion(chart) {
if (!chart?.canvas) {
return;
}
chart.canvas.addEventListener('click', (event) => {
const legend = chart.legend;
if (!legend || !legend.legendHitBoxes) {
return;
}
const rect = chart.canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const clickedLegendItem = legend.legendHitBoxes.some((box) => {
return x >= box.left
&& x <= box.left + box.width
&& y >= box.top
&& y <= box.top + box.height;
});
if (clickedLegendItem) {
event.stopPropagation();
}
});
}
const leaderboardCtx = document.getElementById('leaderboardChart');
const resetZoomButton = document.getElementById('resetLeaderboardZoom');
let leaderboardChart = null;
if (leaderboardCtx && leaderboardChartData.labels.length > 0) {
leaderboardChart = new Chart(
leaderboardCtx,
buildLeaderboardChartConfig(mainLeaderboardChartData(leaderboardChartData), { compact: true })
);
enableDragZoom(leaderboardChart, resetZoomButton);
suppressLegendClickExpansion(leaderboardChart);
}
const chartModal = document.getElementById('chartModal');
const expandButton = document.getElementById('expandLeaderboardChart');
const chartFrame = document.getElementById('leaderboardChartFrame');
const fullscreenButton = document.getElementById('fullscreenLeaderboardChart');
const modalFrame = document.getElementById('chartModalFrame');
const expandedCtx = document.getElementById('leaderboardChartExpanded');
let expandedChart = null;
function openChartModal() {
if (!chartModal || !expandedCtx || leaderboardChartData.labels.length === 0) {
return;
}
chartModal.classList.add('is-open');
chartModal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
if (expandedChart) {
expandedChart.destroy();
}
expandedChart = new Chart(expandedCtx, buildLeaderboardChartConfig(leaderboardChartData));
}
function closeChartModal() {
if (!chartModal) {
return;
}
chartModal.classList.remove('is-open');
chartModal.setAttribute('aria-hidden', 'true');
document.body.classList.remove('modal-open');
if (expandedChart) {
expandedChart.destroy();
expandedChart = null;
}
}
expandButton?.addEventListener('click', openChartModal);
chartFrame?.addEventListener('click', openChartModal);
chartFrame?.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openChartModal();
}
});
chartModal?.querySelectorAll('[data-close-chart-modal]').forEach((element) => {
element.addEventListener('click', closeChartModal);
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && chartModal?.classList.contains('is-open')) {
closeChartModal();
}
});
fullscreenButton?.addEventListener('click', async () => {
if (!modalFrame) {
return;
}
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
if (modalFrame.requestFullscreen) {
await modalFrame.requestFullscreen();
}
});
const leaderboardTable = document.getElementById('leaderboardTable');
const sortButtons = Array.from(document.querySelectorAll('.sort-button'));
const columnIndexMap = {
rank: 0,
rankdelta: 1,
player: 2,
cashin: 3,
paidout: 4,
net: 5,
winpct: 6,
roi: 7,
avgwin: 8,
avgloss: 9,
sessions: 10,
};
function updateRankCells() {
if (!leaderboardTable) {
return;
}
Array.from(leaderboardTable.tBodies[0].querySelectorAll('tr[data-player-row="true"]')).forEach((row, index) => {
const rankCell = row.querySelector('[data-rank-cell]');
if (rankCell) {
rankCell.textContent = `#${index + 1}`;
}
});
}
function setActiveSortButton(sortKey, direction) {
sortButtons.forEach((button) => {
const isActive = button.dataset.sortKey === sortKey;
button.classList.toggle('is-active', isActive);
const indicator = button.querySelector('.sort-indicator');
if (indicator) {
indicator.textContent = isActive ? (direction === 'asc' ? '↑' : '↓') : '↕';
}
});
}
function sortLeaderboard(sortKey, sortType, forcedDirection) {
if (!leaderboardTable) {
return;
}
const tbody = leaderboardTable.tBodies[0];
const rows = Array.from(tbody.querySelectorAll('tr[data-player-row="true"]'));
const currentKey = leaderboardTable.dataset.sortKey;
const currentDirection = leaderboardTable.dataset.sortDirection || 'desc';
let direction = forcedDirection;
if (!direction) {
if (currentKey === sortKey) {
direction = currentDirection === 'desc' ? 'asc' : 'desc';
} else {
const trigger = sortButtons.find((button) => button.dataset.sortKey === sortKey);
direction = trigger?.dataset.initialDirection || 'desc';
}
}
const multiplier = direction === 'asc' ? 1 : -1;
const columnIndex = columnIndexMap[sortKey] ?? 0;
rows.sort((leftRow, rightRow) => {
const leftCell = leftRow.children[columnIndex];
const rightCell = rightRow.children[columnIndex];
const leftValue = leftCell?.dataset.sortValue ?? '';
const rightValue = rightCell?.dataset.sortValue ?? '';
if (sortType === 'number') {
const numericResult = (Number(leftValue) - Number(rightValue)) * multiplier;
if (numericResult !== 0) {
return numericResult;
}
} else {
const stringResult = leftValue.localeCompare(rightValue) * multiplier;
if (stringResult !== 0) {
return stringResult;
}
}
return Number(leftRow.dataset.originalRank) - Number(rightRow.dataset.originalRank);
});
rows.forEach((row) => tbody.appendChild(row));
leaderboardTable.dataset.sortKey = sortKey;
leaderboardTable.dataset.sortDirection = direction;
updateRankCells();
setActiveSortButton(sortKey, direction);
}
sortButtons.forEach((button) => {
button.addEventListener('click', () => {
sortLeaderboard(button.dataset.sortKey, button.dataset.sortType);
});
});
setActiveSortButton('net', 'desc');
</script>
{% endblock %}