478 lines
18 KiB
HTML
478 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Darts Stats Dashboard</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<style>
|
|
:root {
|
|
--primary: #22c55e; /* Green */
|
|
--primary-hover: #16a34a;
|
|
--bg: #0f172a; /* Dark slate */
|
|
--card-bg: #1e293b;
|
|
--text-main: #f8fafc;
|
|
--text-muted: #94a3b8;
|
|
--border: #334155;
|
|
}
|
|
body {
|
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
margin: 0;
|
|
padding: 20px;
|
|
background-color: var(--bg);
|
|
color: var(--text-main);
|
|
line-height: 1.5;
|
|
}
|
|
.container { max-width: 1200px; margin: auto; }
|
|
h1 { font-size: 2rem; font-weight: 800; margin-bottom: 2rem; display: flex; align-items: center; gap: 10px; color: var(--primary); }
|
|
|
|
.card {
|
|
background: var(--card-bg);
|
|
padding: 24px;
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.2);
|
|
margin-bottom: 24px;
|
|
border: 1px solid var(--border);
|
|
}
|
|
h2 { font-size: 1.25rem; font-weight: 700; margin-top: 0; margin-bottom: 1.5rem; color: var(--text-main); }
|
|
|
|
.upload-section {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
background: #14532d33; /* Dark green tint */
|
|
border: 1px dashed var(--primary);
|
|
}
|
|
input[type="file"] {
|
|
font-size: 0.9rem;
|
|
color: var(--text-muted);
|
|
}
|
|
button {
|
|
background: var(--primary);
|
|
color: #052e16; /* Dark green text for contrast */
|
|
border: none;
|
|
padding: 8px 16px;
|
|
border-radius: 6px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
button:hover { background: var(--primary-hover); transform: translateY(-1px); }
|
|
|
|
.btn-secondary {
|
|
background: #64748b !important;
|
|
color: #f8fafc !important;
|
|
}
|
|
.btn-secondary:hover {
|
|
background: #94a3b8 !important;
|
|
}
|
|
|
|
.icon-button:hover {
|
|
color: var(--primary) !important;
|
|
transform: scale(1.1);
|
|
}
|
|
|
|
#notification {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
background: var(--primary);
|
|
color: #052e16;
|
|
padding: 12px 24px;
|
|
border-radius: 8px;
|
|
font-weight: 700;
|
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
|
|
transform: translateY(100px);
|
|
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
z-index: 1000;
|
|
}
|
|
#notification.show {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.player-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
.player-header {
|
|
font-size: 1.4rem;
|
|
font-weight: 800;
|
|
border-bottom: 2px solid var(--border);
|
|
padding-bottom: 8px;
|
|
color: var(--primary);
|
|
}
|
|
.stat-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid #33415544;
|
|
}
|
|
.stat-label { color: var(--text-muted); font-size: 0.9rem; font-weight: 500; }
|
|
.stat-value { font-size: 1.1rem; font-weight: 700; color: var(--text-main); }
|
|
|
|
table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 0.95rem; }
|
|
th {
|
|
background-color: #0f172a;
|
|
padding: 12px;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
color: var(--primary);
|
|
cursor: pointer;
|
|
user-select: none;
|
|
transition: background 0.2s;
|
|
border-bottom: 2px solid var(--border);
|
|
}
|
|
th:hover { background-color: var(--border); }
|
|
td { padding: 12px; border-bottom: 1px solid var(--border); color: var(--text-main); }
|
|
tr:hover td { background-color: #ffffff05; }
|
|
tr:last-child td { border-bottom: none; }
|
|
|
|
.badge {
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
font-size: 0.8rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
}
|
|
.badge-win { background: #064e3b; color: #34d399; }
|
|
.badge-loss { background: #7f1d1d; color: #fca5a5; }
|
|
|
|
.chart-container { position: relative; height: 350px; width: 100%; }
|
|
|
|
/* Responsive adjustments */
|
|
@media (max-width: 600px) {
|
|
body { padding: 12px; }
|
|
h1 { font-size: 1.5rem; margin-bottom: 1.5rem; }
|
|
.card { padding: 16px; margin-bottom: 16px; }
|
|
|
|
.upload-section {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
text-align: center;
|
|
}
|
|
|
|
.stats-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.table-container {
|
|
overflow-x: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
margin: 0 -16px;
|
|
padding: 0 16px;
|
|
}
|
|
|
|
table {
|
|
min-width: 600px; /* Force scroll on small screens */
|
|
}
|
|
|
|
.chart-container { height: 250px; }
|
|
}
|
|
|
|
footer {
|
|
margin-top: 40px;
|
|
padding: 20px 0;
|
|
border-top: 1px solid var(--border);
|
|
text-align: center;
|
|
color: var(--text-muted);
|
|
font-size: 0.85rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>🎯 Darts Statistics Dashboard</h1>
|
|
|
|
<div class="card upload-section">
|
|
<div style="flex-grow: 1">
|
|
<h2 style="margin: 0 0 8px 0; font-size: 1.1rem">Upload Stats JSON</h2>
|
|
<div style="display: flex; align-items: center; gap: 10px;">
|
|
<input type="file" id="fileInput" accept=".json">
|
|
<button onclick="uploadFile()">Upload</button>
|
|
<button onclick="clearCache()" class="btn-secondary">Clear Cache</button>
|
|
</div>
|
|
</div>
|
|
<div id="currentCodeDisplay" style="background: #0f172a; padding: 10px 15px; border-radius: 8px; border: 1px solid var(--border); display: flex; flex-direction: column; align-items: center; min-width: 110px;">
|
|
<div style="font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; font-weight: 700;">Current Code</div>
|
|
<div style="display: flex; align-items: center; gap: 10px; margin-top: 4px;">
|
|
<div id="activeCode" style="font-size: 1.3rem; font-weight: 800; color: var(--primary); font-family: 'JetBrains Mono', monospace;">NONE</div>
|
|
<span onclick="copyUrlToClipboard()" class="icon-button" style="cursor: pointer; font-size: 1.2rem; line-height: 1; filter: grayscale(1) brightness(1.5);" title="Copy URL to clipboard">
|
|
📋
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="player-summaries" class="stats-grid"></div>
|
|
|
|
<div class="card">
|
|
<h2>Average Progression</h2>
|
|
<div class="chart-container">
|
|
<canvas id="avgChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Recent Games</h2>
|
|
<div class="table-container">
|
|
<table id="games-table">
|
|
<thead>
|
|
<tr>
|
|
<th onclick="sortTable(0)">Date ↕</th>
|
|
<th onclick="sortTable(1)">Player ↕</th>
|
|
<th onclick="sortTable(2)">Opponent ↕</th>
|
|
<th onclick="sortTable(3)">Avg ↕</th>
|
|
<th onclick="sortTable(4)">Darts ↕</th>
|
|
<th onclick="sortTable(5)">Round ↕</th>
|
|
<th onclick="sortTable(6)">Result ↕</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="notification"></div>
|
|
|
|
<script>
|
|
function showNotification(message, isError = false) {
|
|
const nav = document.getElementById('notification');
|
|
nav.innerText = message;
|
|
nav.style.background = isError ? '#ef4444' : 'var(--primary)';
|
|
nav.style.color = isError ? '#fff' : '#052e16';
|
|
nav.classList.add('show');
|
|
setTimeout(() => {
|
|
nav.classList.remove('show');
|
|
}, 3000);
|
|
}
|
|
|
|
async function uploadFile() {
|
|
const fileInput = document.getElementById('fileInput');
|
|
if (!fileInput.files[0]) {
|
|
showNotification('Please select a file', true);
|
|
return;
|
|
}
|
|
|
|
const file = fileInput.files[0];
|
|
const reader = new FileReader();
|
|
reader.onload = async (e) => {
|
|
try {
|
|
const response = await fetch('/api/upload', {
|
|
method: 'POST',
|
|
body: e.target.result,
|
|
redirect: 'follow'
|
|
});
|
|
if (response.ok) {
|
|
showNotification('Upload successful!');
|
|
const url = new URL(response.url);
|
|
const code = url.searchParams.get('code');
|
|
if (code) {
|
|
window.history.pushState({}, '', `/?code=${code}`);
|
|
loadStats();
|
|
}
|
|
} else {
|
|
showNotification('Upload failed: ' + await response.text(), true);
|
|
}
|
|
} catch (err) {
|
|
showNotification('Error: ' + err.message, true);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
async function clearCache() {
|
|
if (!confirm('Are you sure you want to clear all data?')) return;
|
|
try {
|
|
const response = await fetch('/api/clear', { method: 'POST' });
|
|
if (response.ok) {
|
|
showNotification('Cache cleared!');
|
|
loadStats();
|
|
} else {
|
|
showNotification('Clear failed: ' + await response.text(), true);
|
|
}
|
|
} catch (err) {
|
|
showNotification('Error: ' + err.message, true);
|
|
}
|
|
}
|
|
|
|
function copyUrlToClipboard() {
|
|
const code = document.getElementById('activeCode').innerText;
|
|
if (code === 'NONE') return;
|
|
|
|
const url = window.location.href;
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
showNotification('URL copied to clipboard!');
|
|
}).catch(err => {
|
|
console.error('Could not copy text: ', err);
|
|
});
|
|
}
|
|
|
|
async function loadStats() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const code = urlParams.get('code');
|
|
|
|
const displayCode = document.getElementById('activeCode');
|
|
displayCode.innerText = code ? code.toUpperCase() : 'NONE';
|
|
|
|
const response = await fetch(`/api/stats${code ? `?code=${code}` : ''}`);
|
|
const data = await response.json();
|
|
|
|
renderSummaries(data.playerStats);
|
|
renderChart(data.games);
|
|
renderTable(data.games);
|
|
}
|
|
|
|
function renderSummaries(playerStats) {
|
|
const container = document.getElementById('player-summaries');
|
|
container.innerHTML = '';
|
|
|
|
for (const [player, stats] of Object.entries(playerStats)) {
|
|
const card = document.createElement('div');
|
|
card.className = 'card player-card';
|
|
card.innerHTML = `
|
|
<div class="player-header">${player}</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">Overall Average</span>
|
|
<span class="stat-value">${stats.overallAverage}</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">Win Rate</span>
|
|
<span class="stat-value">${stats.winRate}%</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">Total 180s</span>
|
|
<span class="stat-value">${stats.total180s}</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">Games Played</span>
|
|
<span class="stat-value">${stats.totalGames}</span>
|
|
</div>
|
|
`;
|
|
container.appendChild(card);
|
|
}
|
|
}
|
|
|
|
function renderChart(games) {
|
|
const ctx = document.getElementById('avgChart').getContext('2d');
|
|
if (window.myChart) window.myChart.destroy();
|
|
|
|
const players = [...new Set(games.map(g => g.player))];
|
|
const dates = [...new Set(games.map(g => g.date))].sort((a, b) => new Date(a) - new Date(b));
|
|
|
|
const datasets = players.map((player, index) => {
|
|
const playerGames = games.filter(g => g.player === player);
|
|
const data = dates.map(date => {
|
|
const game = playerGames.find(g => g.date === date);
|
|
return game ? game.average : null;
|
|
});
|
|
|
|
const colors = ['#22c55e', '#3b82f6', '#a855f7', '#f59e0b'];
|
|
const color = colors[index % colors.length];
|
|
|
|
return {
|
|
label: player,
|
|
data: data,
|
|
borderColor: color,
|
|
backgroundColor: color + '22',
|
|
fill: true,
|
|
tension: 0.3,
|
|
spanGaps: true
|
|
};
|
|
});
|
|
|
|
window.myChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: dates,
|
|
datasets: datasets
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { labels: { color: '#f8fafc', font: { weight: '600' } } }
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: false,
|
|
grid: { color: '#334155' },
|
|
ticks: { color: '#94a3b8' },
|
|
title: { display: true, text: '3-Dart Average', color: '#94a3b8' }
|
|
},
|
|
x: {
|
|
grid: { color: '#334155' },
|
|
ticks: { color: '#94a3b8' }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderTable(games) {
|
|
const tbody = document.querySelector('#games-table tbody');
|
|
tbody.innerHTML = '';
|
|
|
|
const sortedGames = games.slice().sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
|
|
sortedGames.forEach(game => {
|
|
const tr = document.createElement('tr');
|
|
const resultClass = game.result === 'WIN' ? 'badge-win' : 'badge-loss';
|
|
const round = Math.ceil(game.dartsThrown / 3);
|
|
tr.innerHTML = `
|
|
<td style="font-weight: 500">${game.date}</td>
|
|
<td style="font-weight: 600">${game.player}</td>
|
|
<td>${game.opponent}</td>
|
|
<td style="font-weight: 700; color: var(--primary)">${game.average}</td>
|
|
<td>${game.dartsThrown}</td>
|
|
<td>${round}</td>
|
|
<td><span class="badge ${resultClass}">${game.result}</span></td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function sortTable(n) {
|
|
const table = document.getElementById("games-table");
|
|
const tbody = table.querySelector("tbody");
|
|
const rows = Array.from(tbody.rows);
|
|
|
|
let dir = table.getAttribute("data-sort-dir") === "asc" && table.getAttribute("data-sort-col") == n ? "desc" : "asc";
|
|
|
|
rows.sort((a, b) => {
|
|
let x = a.cells[n].textContent.trim().toLowerCase();
|
|
let y = b.cells[n].textContent.trim().toLowerCase();
|
|
|
|
if (n === 3 || n === 4 || n === 5) { // Numeric (Avg, Darts, Round)
|
|
return dir === "asc" ? parseFloat(x) - parseFloat(y) : parseFloat(y) - parseFloat(x);
|
|
}
|
|
if (n === 0) { // Date
|
|
return dir === "asc" ? new Date(x) - new Date(y) : new Date(y) - new Date(x);
|
|
}
|
|
// String
|
|
return dir === "asc" ? x.localeCompare(y) : y.localeCompare(x);
|
|
});
|
|
|
|
table.setAttribute("data-sort-dir", dir);
|
|
table.setAttribute("data-sort-col", n);
|
|
|
|
rows.forEach(row => tbody.appendChild(row));
|
|
}
|
|
|
|
loadStats();
|
|
</script>
|
|
<footer>
|
|
© 2025 kAmMa | Version 1.0
|
|
</footer>
|
|
</body>
|
|
</html>
|