diff --git a/app.py b/app.py
index 35a7d66..48ae2f9 100644
--- a/app.py
+++ b/app.py
@@ -29,6 +29,7 @@ from stats import (
session_breakdown_series,
session_events,
session_label,
+ session_sort_key,
unique_player_names,
)
from storage import append_event, ensure_data_file, load_events
@@ -102,7 +103,7 @@ def home() -> str:
def leaderboard() -> str:
events = load_events(DATA_PATH)
all_sessions = build_session_summaries(events)
- ordered_sessions = sorted(all_sessions, key=lambda s: s.session_date)
+ ordered_sessions = sorted(all_sessions, key=session_sort_key)
session_ids = [session.session_id for session in ordered_sessions]
selected_session_id = request.args.get("through_session", "").strip()
@@ -111,6 +112,7 @@ def leaderboard() -> str:
if session_ids:
if selected_session_id in session_ids:
cutoff_index = session_ids.index(selected_session_id)
+ label = session_label(ordered_sessions[cutoff_index])
else:
cutoff_index = len(session_ids) - 1
selected_session = ordered_sessions[cutoff_index]
@@ -137,6 +139,11 @@ def leaderboard() -> str:
available_sessions=all_sessions,
selected_session_id=selected_session_id,
selected_session_label=(label if selected_session_id else "Latest session"),
+ selected_session_date=(
+ ordered_sessions[cutoff_index].session_date
+ if selected_session_id and session_ids
+ else ""
+ ),
session_label=session_label,
)
diff --git a/static/css/style.css b/static/css/style.css
index f77f9b9..1655265 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -318,6 +318,10 @@ h2 {
grid-template-columns: minmax(0, 1.65fr) minmax(320px, 0.95fr);
}
+.leaderboard-layout {
+ grid-template-columns: minmax(0, 2.3fr) minmax(280px, 0.7fr);
+}
+
.three-col {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -415,7 +419,7 @@ h2 {
}
.chart-panel-large {
- min-height: 540px;
+ min-height: 600px;
}
.chart-frame,
@@ -425,7 +429,7 @@ h2 {
}
.chart-panel-large .chart-frame {
- min-height: 450px;
+ min-height: 510px;
}
.chart-frame-interactive {
diff --git a/stats.py b/stats.py
index 7d8d75a..59fe04e 100644
--- a/stats.py
+++ b/stats.py
@@ -212,20 +212,21 @@ def cents_to_dollars(cents: int) -> str:
return f"${value:,.2f}"
-def session_sort_key(session: SessionSummary) -> tuple[str, int, str]:
+def session_sort_key(session: SessionSummary) -> tuple[str, int, str, str]:
session_id = session.session_id.strip()
+ session_date = session.session_date.strip()
- if session_id == session.session_date:
- return (session.session_date, 1, session_id)
+ if session_id == session_date:
+ return (session_date, 1, session.opened_at, session_id)
- suffix = session_id.replace(f"{session.session_date}-", "")
+ suffix = session_id.replace(f"{session_date}-", "")
if suffix.isdigit():
- return (session.session_date, int(suffix), session_id)
+ return (session_date, int(suffix), session.opened_at, session_id)
if suffix.lower().startswith("s") and suffix[1:].isdigit():
- return (session.session_date, int(suffix[1:]), session_id)
+ return (session_date, int(suffix[1:]), session.opened_at, session_id)
- return (session.session_date, 9999, session_id)
+ return (session_date, 9999, session.opened_at, session_id)
def session_chart_label(session: SessionSummary) -> str:
@@ -518,6 +519,7 @@ def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, object
for player_name in all_players:
running_total = 0
seen_player = False
+ sessions_played = 0
data: list[float | None] = []
for session in ordered_sessions:
@@ -535,6 +537,7 @@ def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, object
continue
seen_player = True
+ sessions_played += 1
running_total += matching_entry.net_cents
data.append(round(running_total / 100, 2))
@@ -542,6 +545,7 @@ def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, object
{
"label": player_name,
"data": data,
+ "sessions_played": sessions_played,
}
)
diff --git a/templates/leaderboard.html b/templates/leaderboard.html
index 8228f9c..1bf7cc9 100644
--- a/templates/leaderboard.html
+++ b/templates/leaderboard.html
@@ -17,6 +17,7 @@
Cumulative profit over time
+
@@ -137,7 +138,70 @@
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
const lineColor = rootStyles.getPropertyValue('--line').trim();
- function buildLeaderboardChartConfig(chartData) {
+ 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,
@@ -145,15 +209,26 @@
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: 10,
- boxHeight: 10,
- padding: 18,
+ boxWidth: compact ? 8 : 10,
+ boxHeight: compact ? 8 : 10,
+ padding: compact ? 12 : 18,
},
},
tooltip: {
@@ -170,27 +245,186 @@
},
scales: {
x: {
- ticks: { color: textMuted, maxRotation: 0 },
- grid: { color: lineColor },
- },
- y: {
ticks: {
color: textMuted,
- callback(value) {
- return `$${value}`;
- },
+ maxRotation: 0,
+ autoSkip: true,
+ maxTicksLimit: compact ? 6 : 10,
+ },
+ grid: {
+ color: lineColor,
+ display: !compact,
},
- grid: { color: lineColor },
},
+ 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(leaderboardChartData));
+ leaderboardChart = new Chart(
+ leaderboardCtx,
+ buildLeaderboardChartConfig(mainLeaderboardChartData(leaderboardChartData), { compact: true })
+ );
+ enableDragZoom(leaderboardChart, resetZoomButton);
+ suppressLegendClickExpansion(leaderboardChart);
}
const chartModal = document.getElementById('chartModal');