UI and data improvements

This commit is contained in:
Radek Davidek 2026-06-05 09:57:19 +02:00
parent 53c290fc9f
commit 6d77ebc84a
5 changed files with 88294 additions and 32114 deletions

87725
X01Stats_20260605_093832.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +1,29 @@
package cz.kamma.darts;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.*;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
public class App {
private static final Gson GSON = new Gson();
private static final Map<String, CachedData> GAMES_CACHE = new ConcurrentHashMap<>();
@ -26,10 +32,12 @@ public class App {
static class CachedData {
final List<DartsGame> games;
final List<RawGame> rawGames;
final long expiryTime;
CachedData(List<DartsGame> games) {
CachedData(List<DartsGame> games, List<RawGame> rawGames) {
this.games = games;
this.rawGames = rawGames;
this.expiryTime = System.currentTimeMillis() + TTL_MS;
}
@ -41,12 +49,12 @@ public class App {
public static void main(String[] args) throws IOException {
int port = 8080;
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
server.createContext("/", new StaticHandler());
server.createContext("/api/stats", new StatsHandler());
server.createContext("/api/upload", new UploadHandler());
server.createContext("/api/clear", new ClearHandler());
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
GAMES_CACHE.entrySet().removeIf(entry -> entry.getValue().isExpired());
@ -61,8 +69,9 @@ public class App {
@Override
public void handle(HttpExchange exchange) throws IOException {
String path = exchange.getRequestURI().getPath();
if (path.equals("/")) path = "/index.html";
if (path.equals("/"))
path = "/index.html";
InputStream is = App.class.getResourceAsStream("/web" + path);
if (is == null) {
String response = "404 Not Found";
@ -75,8 +84,10 @@ public class App {
byte[] content = is.readAllBytes();
String contentType = "text/html";
if (path.endsWith(".js")) contentType = "application/javascript";
if (path.endsWith(".css")) contentType = "text/css";
if (path.endsWith(".js"))
contentType = "application/javascript";
if (path.endsWith(".css"))
contentType = "text/css";
exchange.getResponseHeaders().set("Content-Type", contentType);
exchange.sendResponseHeaders(200, content.length);
@ -123,14 +134,15 @@ public class App {
byte[] bytes = baos.toByteArray();
String body = new String(bytes, StandardCharsets.UTF_8);
try {
List<RawGame> rawGames = GSON.fromJson(body, new TypeToken<List<RawGame>>(){}.getType());
List<RawGame> rawGames = GSON.fromJson(body, new TypeToken<List<RawGame>>() {
}.getType());
List<DartsGame> convertedGames = convertRawToGames(rawGames);
String code = generateCode();
GAMES_CACHE.put(code, new CachedData(convertedGames));
GAMES_CACHE.put(code, new CachedData(convertedGames, rawGames));
exchange.getResponseHeaders().set("Location", "/?code=" + code);
exchange.sendResponseHeaders(303, -1);
} catch (Exception e) {
@ -165,42 +177,51 @@ public class App {
.collect(Collectors.joining(", ")));
game.setGameType(String.valueOf(rg.getTargetScore()));
game.setResult(rp.getName().equals(rg.getWinnerName()) ? "WIN" : "LOSS");
List<RawGame.RawThrow> throwsList = rp.getThrowsList();
game.setDartsThrown(throwsList.size());
int totalScore = throwsList.stream().filter(t -> !t.isBust()).mapToInt(RawGame.RawThrow::getValue).sum();
game.setAverage(throwsList.isEmpty() ? 0 : Math.round((double) totalScore / throwsList.size() * 3 * 100.0) / 100.0);
int totalScore = throwsList.stream().filter(t -> !t.isBust()).mapToInt(RawGame.RawThrow::getValue)
.sum();
game.setAverage(throwsList.isEmpty() ? 0
: Math.round((double) totalScore / throwsList.size() * 3 * 100.0) / 100.0);
// Calculate visits
int h100 = 0, h140 = 0, h180 = 0, highest = 0;
for (int i = 0; i < throwsList.size(); i += 3) {
int visitSum = 0;
boolean bust = false;
for (int j = 0; j < 3 && (i + j) < throwsList.size(); j++) {
if (throwsList.get(i + j).isBust()) bust = true;
if (throwsList.get(i + j).isBust())
bust = true;
visitSum += throwsList.get(i + j).getValue();
}
if (bust) visitSum = 0;
if (visitSum >= 180) h180++;
else if (visitSum >= 140) h140++;
else if (visitSum >= 100) h100++;
if (visitSum > highest) highest = visitSum;
if (bust)
visitSum = 0;
if (visitSum >= 180)
h180++;
else if (visitSum >= 140)
h140++;
else if (visitSum >= 100)
h100++;
if (visitSum > highest)
highest = visitSum;
}
game.setHundredPlus(h100);
game.setHundredFortyPlus(h140);
game.setHundredEighty(h180);
game.setHighestVisit(highest);
result.add(game);
}
}
return result;
}
}
private static void sendError(HttpExchange exchange, int statusCode, String message) throws IOException {
byte[] responseBytes = message.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(statusCode, responseBytes.length);
@ -208,6 +229,7 @@ public class App {
os.write(responseBytes);
}
}
static class StatsHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
@ -218,15 +240,17 @@ public class App {
Map<String, String> params = parseQuery(exchange.getRequestURI().getQuery());
String code = params.get("code");
CachedData cached = (code != null) ? GAMES_CACHE.get(code.toUpperCase()) : null;
List<DartsGame> games = (cached != null && !cached.isExpired()) ? cached.games : Collections.emptyList();
List<RawGame> rawGames = (cached != null && !cached.isExpired()) ? cached.rawGames
: Collections.emptyList();
StatsResponse response = aggregateStats(new ArrayList<>(games), new ArrayList<>(rawGames));
StatsResponse response = aggregateStats(new ArrayList<>(games));
String json = GSON.toJson(response);
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, bytes.length);
OutputStream os = exchange.getResponseBody();
@ -235,7 +259,8 @@ public class App {
}
private Map<String, String> parseQuery(String query) {
if (query == null) return Collections.emptyMap();
if (query == null)
return Collections.emptyMap();
Map<String, String> result = new HashMap<>();
for (String param : query.split("&")) {
String[] entry = param.split("=");
@ -246,16 +271,16 @@ public class App {
return result;
}
private StatsResponse aggregateStats(List<DartsGame> games) {
private StatsResponse aggregateStats(List<DartsGame> games, List<RawGame> rawGames) {
Map<String, List<DartsGame>> byPlayer = games.stream()
.collect(Collectors.groupingBy(DartsGame::getPlayer));
Map<String, StatsResponse.PlayerStats> playerStatsMap = new HashMap<>();
for (Map.Entry<String, List<DartsGame>> entry : byPlayer.entrySet()) {
String player = entry.getKey();
List<DartsGame> playerGames = entry.getValue();
double avg = playerGames.stream().mapToDouble(DartsGame::getAverage).average().orElse(0.0);
int wins = (int) playerGames.stream().filter(g -> "WIN".equalsIgnoreCase(g.getResult())).count();
int h100 = playerGames.stream().mapToInt(DartsGame::getHundredPlus).sum();
@ -275,6 +300,7 @@ public class App {
return StatsResponse.builder()
.games(games)
.rawGames(rawGames)
.playerStats(playerStatsMap)
.build();
}

View File

@ -9,6 +9,7 @@ import java.util.Map;
@Builder
public class StatsResponse {
private List<DartsGame> games;
private List<RawGame> rawGames;
private Map<String, PlayerStats> playerStats;
@Data

View File

@ -43,6 +43,34 @@
background: #14532d33; /* Dark green tint */
border: 1px dashed var(--primary);
}
.filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.filter-control {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 220px;
}
.filter-control label {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.filter-control select {
background: #0f172a;
color: var(--text-main);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
font-size: 0.95rem;
}
input[type="file"] {
font-size: 0.9rem;
color: var(--text-muted);
@ -145,6 +173,172 @@
}
.badge-win { background: #064e3b; color: #34d399; }
.badge-loss { background: #7f1d1d; color: #fca5a5; }
.game-link {
background: none;
border: none;
padding: 0;
color: var(--primary);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.game-link:hover {
color: #86efac;
transform: none;
text-decoration: underline;
}
.modal {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(15, 23, 42, 0.82);
z-index: 1100;
}
.modal.open {
display: flex;
}
.modal-card {
width: min(960px, 100%);
max-height: 90vh;
overflow-y: auto;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
padding: 24px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 20px;
}
.modal-meta {
color: var(--text-muted);
font-size: 0.92rem;
}
.modal-close {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
padding: 8px 12px;
}
.modal-close:hover {
color: var(--text-main);
background: #33415555;
}
.game-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.game-summary-item {
background: #0f172a;
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
}
.game-summary-label {
color: var(--text-muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 6px;
}
.game-summary-value {
font-size: 1.05rem;
font-weight: 700;
}
.visit-list {
display: grid;
gap: 16px;
}
.visit-card {
background: #0f172a;
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
}
.visit-card-header {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.visit-card-title {
font-weight: 700;
color: var(--primary);
}
.throw-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 8px 0;
}
.throw-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 46px;
padding: 6px 10px;
border-radius: 999px;
background: #1e293b;
border: 1px solid var(--border);
font-weight: 700;
}
.throw-pill.bust {
background: #7f1d1d33;
border-color: #7f1d1d;
color: #fecaca;
}
.visit-note {
color: var(--text-muted);
font-size: 0.9rem;
}
.round-list {
display: grid;
gap: 18px;
}
.round-card {
background: #0f172a;
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
}
.round-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.round-title {
font-size: 1rem;
font-weight: 800;
color: var(--primary);
}
.round-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.round-player-card {
background: #1e293b;
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
}
.round-player-name {
font-weight: 700;
margin-bottom: 10px;
}
.chart-container { position: relative; height: 350px; width: 100%; }
@ -159,6 +353,12 @@
align-items: stretch;
text-align: center;
}
.filter-bar {
align-items: stretch;
}
.filter-control {
min-width: 0;
}
.stats-grid {
grid-template-columns: 1fr;
@ -176,6 +376,12 @@
}
.chart-container { height: 250px; }
.modal {
padding: 12px;
}
.modal-card {
padding: 16px;
}
}
footer {
@ -218,6 +424,19 @@
</div>
</div>
<div class="card filter-bar">
<div>
<h2 style="margin-bottom: 0.35rem;">Filter by Day</h2>
<div style="color: var(--text-muted); font-size: 0.9rem;">Vyber den ze statistiky a dashboard se překreslí jen pro tento výběr.</div>
</div>
<div class="filter-control">
<label for="dateFilter">Day</label>
<select id="dateFilter">
<option value="__all__">All days</option>
</select>
</div>
</div>
<div id="player-summaries" class="stats-grid"></div>
<div class="card">
@ -234,12 +453,13 @@
<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>
<th onclick="sortTable(1)">Game # ↕</th>
<th onclick="sortTable(2)">Player ↕</th>
<th onclick="sortTable(3)">Opponent ↕</th>
<th onclick="sortTable(4)">Avg ↕</th>
<th onclick="sortTable(5)">Darts ↕</th>
<th onclick="sortTable(6)">Round ↕</th>
<th onclick="sortTable(7)">Result ↕</th>
</tr>
</thead>
<tbody></tbody>
@ -249,8 +469,24 @@
</div>
<div id="notification"></div>
<div id="gameDetailModal" class="modal" role="dialog" aria-modal="true" aria-labelledby="gameDetailTitle">
<div class="modal-card">
<div class="modal-header">
<div>
<h2 id="gameDetailTitle" style="margin-bottom: 0.35rem;">Game Detail</h2>
<div id="gameDetailMeta" class="modal-meta"></div>
</div>
<button type="button" class="modal-close" onclick="closeGameDetail()">Close</button>
</div>
<div id="gameDetailContent"></div>
</div>
</div>
<script>
let allGames = [];
let allRawGames = [];
let selectedDay = '__all__';
function showNotification(message, isError = false) {
const nav = document.getElementById('notification');
nav.innerText = message;
@ -332,10 +568,68 @@
const response = await fetch(`/api/stats${code ? `?code=${code}` : ''}`);
const data = await response.json();
renderSummaries(data.playerStats);
renderChart(data.games);
renderTable(data.games);
allGames = data.games || [];
allRawGames = data.rawGames || [];
populateDateFilter(allGames);
renderFilteredDashboard();
}
function extractDayLabel(dateString) {
const match = dateString.match(/^[A-Z][a-z]{2} \d{1,2}, \d{4}/);
return match ? match[0] : dateString;
}
function populateDateFilter(games) {
const dateFilter = document.getElementById('dateFilter');
const days = [...new Set(games.map(game => extractDayLabel(game.date)))].sort((a, b) => new Date(b) - new Date(a));
const previousSelection = days.includes(selectedDay) ? selectedDay : '__all__';
dateFilter.innerHTML = '<option value="__all__">All days</option>';
days.forEach(day => {
const option = document.createElement('option');
option.value = day;
option.textContent = day;
dateFilter.appendChild(option);
});
selectedDay = previousSelection;
dateFilter.value = previousSelection;
}
function getFilteredGames() {
if (selectedDay === '__all__') {
return allGames;
}
return allGames.filter(game => extractDayLabel(game.date) === selectedDay);
}
function aggregatePlayerStats(games) {
return games.reduce((acc, game) => {
if (!acc[game.player]) {
acc[game.player] = {
totalAverage: 0,
totalGames: 0,
wins: 0,
total180s: 0
};
}
acc[game.player].totalAverage += game.average;
acc[game.player].totalGames += 1;
acc[game.player].wins += game.result === 'WIN' ? 1 : 0;
acc[game.player].total180s += game.hundredEighty || 0;
return acc;
}, {});
}
function renderFilteredDashboard() {
const filteredGames = getFilteredGames();
const aggregatedStats = aggregatePlayerStats(filteredGames);
renderSummaries(aggregatedStats);
renderChart(filteredGames);
renderTable(filteredGames);
}
function renderSummaries(playerStats) {
@ -343,17 +637,19 @@
container.innerHTML = '';
for (const [player, stats] of Object.entries(playerStats)) {
const overallAverage = stats.totalGames ? Math.round((stats.totalAverage / stats.totalGames) * 100) / 100 : 0;
const winRate = stats.totalGames ? Math.round((stats.wins / stats.totalGames) * 10000) / 100 : 0;
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>
<span class="stat-value">${overallAverage}</span>
</div>
<div class="stat-row">
<span class="stat-label">Win Rate</span>
<span class="stat-value">${stats.winRate}%</span>
<span class="stat-value">${winRate}%</span>
</div>
<div class="stat-row">
<span class="stat-label">Total 180s</span>
@ -371,6 +667,9 @@
function renderChart(games) {
const ctx = document.getElementById('avgChart').getContext('2d');
if (window.myChart) window.myChart.destroy();
if (!games.length) {
return;
}
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));
@ -429,13 +728,16 @@
tbody.innerHTML = '';
const sortedGames = games.slice().sort((a, b) => new Date(b.date) - new Date(a.date));
const gameOrderByTimestamp = buildGameOrderMap(games);
sortedGames.forEach(game => {
const tr = document.createElement('tr');
const resultClass = game.result === 'WIN' ? 'badge-win' : 'badge-loss';
const round = Math.ceil(game.dartsThrown / 3);
const gameOrder = gameOrderByTimestamp.get(game.date) || '-';
tr.innerHTML = `
<td style="font-weight: 500">${game.date}</td>
<td><button type="button" class="game-link" onclick="openGameDetail('${escapeForAttribute(game.date)}')">${gameOrder}</button></td>
<td style="font-weight: 600">${game.player}</td>
<td>${game.opponent}</td>
<td style="font-weight: 700; color: var(--primary)">${game.average}</td>
@ -447,6 +749,169 @@
});
}
function buildGameOrderMap(games) {
const uniqueDates = [...new Set(games.map(game => game.date))];
const gamesByDay = uniqueDates.reduce((acc, dateString) => {
const dayLabel = extractDayLabel(dateString);
if (!acc[dayLabel]) {
acc[dayLabel] = [];
}
acc[dayLabel].push(dateString);
return acc;
}, {});
const orderMap = new Map();
Object.values(gamesByDay).forEach(dayGames => {
dayGames
.sort((a, b) => new Date(a) - new Date(b))
.forEach((dateString, index) => {
orderMap.set(dateString, index + 1);
});
});
return orderMap;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeForAttribute(value) {
return escapeHtml(value);
}
function formatVisit(throwsList, targetScore) {
let remaining = targetScore;
return throwsList.reduce((acc, throwItem, index) => {
const visitIndex = Math.floor(index / 3);
if (!acc[visitIndex]) {
acc[visitIndex] = {
throws: [],
score: 0,
isBust: false,
remainingBefore: remaining
};
}
const visit = acc[visitIndex];
visit.throws.push(throwItem);
if (throwItem.isBust) {
visit.isBust = true;
} else {
visit.score += throwItem.value;
}
const isVisitComplete = index % 3 === 2 || index === throwsList.length - 1;
if (isVisitComplete) {
visit.remainingAfter = visit.isBust ? visit.remainingBefore : Math.max(remaining - visit.score, 0);
remaining = visit.remainingAfter;
}
return acc;
}, []);
}
function buildRounds(game) {
const players = (game.players || []).map(player => ({
name: player.name,
visits: formatVisit(player.throwsList || [], game.targetScore || 0)
}));
const maxRounds = players.reduce((max, player) => Math.max(max, player.visits.length), 0);
return Array.from({ length: maxRounds }, (_, roundIndex) => ({
roundNumber: roundIndex + 1,
players: players.map(player => ({
name: player.name,
visit: player.visits[roundIndex] || null
}))
}));
}
function renderGameDetail(game) {
const players = game.players || [];
const content = document.getElementById('gameDetailContent');
const meta = document.getElementById('gameDetailMeta');
const winner = game.winnerName || 'Unknown';
meta.textContent = `${game.date} | ${players.map(player => player.name).join(' vs ')} | Winner: ${winner}`;
const summaryHtml = `
<div class="game-summary-grid">
<div class="game-summary-item">
<div class="game-summary-label">Target</div>
<div class="game-summary-value">${escapeHtml(game.targetScore)}</div>
</div>
<div class="game-summary-item">
<div class="game-summary-label">Players</div>
<div class="game-summary-value">${escapeHtml(players.length)}</div>
</div>
<div class="game-summary-item">
<div class="game-summary-label">Winner</div>
<div class="game-summary-value">${escapeHtml(winner)}</div>
</div>
</div>
`;
const rounds = buildRounds(game);
const roundsHtml = rounds.map(round => {
const playerCards = round.players.map(playerRound => {
if (!playerRound.visit) {
return `
<div class="round-player-card">
<div class="round-player-name">${escapeHtml(playerRound.name)}</div>
<div class="visit-note">No throw in this round.</div>
</div>
`;
}
const throwPills = playerRound.visit.throws.map(throwItem => `
<span class="throw-pill ${throwItem.isBust ? 'bust' : ''}">
${escapeHtml(throwItem.label || throwItem.value)}
</span>
`).join('');
return `
<div class="round-player-card">
<div class="round-player-name">${escapeHtml(playerRound.name)}</div>
<div class="throw-row">${throwPills}</div>
<div class="visit-note">Score: ${playerRound.visit.isBust ? 'BUST' : playerRound.visit.score}</div>
<div class="visit-note">Remaining: ${playerRound.visit.remainingBefore} → ${playerRound.visit.remainingAfter}</div>
</div>
`;
}).join('');
return `
<section class="round-card">
<div class="round-header">
<div class="round-title">Round ${round.roundNumber}</div>
</div>
<div class="round-grid">${playerCards}</div>
</section>
`;
}).join('');
content.innerHTML = summaryHtml + `<div class="round-list">${roundsHtml || '<div class="visit-note">No throws recorded.</div>'}</div>`;
}
function openGameDetail(dateString) {
const rawGame = allRawGames.find(game => game.date === dateString);
if (!rawGame) {
showNotification('Game detail not found', true);
return;
}
renderGameDetail(rawGame);
document.getElementById('gameDetailModal').classList.add('open');
}
function closeGameDetail() {
document.getElementById('gameDetailModal').classList.remove('open');
}
function sortTable(n) {
const table = document.getElementById("games-table");
const tbody = table.querySelector("tbody");
@ -458,7 +923,7 @@
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)
if (n === 1 || n === 4 || n === 5 || n === 6) { // Numeric (Game #, Avg, Darts, Round)
return dir === "asc" ? parseFloat(x) - parseFloat(y) : parseFloat(y) - parseFloat(x);
}
if (n === 0) { // Date
@ -474,6 +939,23 @@
rows.forEach(row => tbody.appendChild(row));
}
document.getElementById('dateFilter').addEventListener('change', (event) => {
selectedDay = event.target.value;
renderFilteredDashboard();
});
document.getElementById('gameDetailModal').addEventListener('click', (event) => {
if (event.target.id === 'gameDetailModal') {
closeGameDetail();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeGameDetail();
}
});
loadStats();
</script>
<footer>

32054
stats.json

File diff suppressed because it is too large Load Diff