2025-12-28 20:56:50 +01:00

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>
&copy; 2025 kAmMa | Version 1.0
</footer>
</body>
</html>