UI and data improvements
This commit is contained in:
parent
53c290fc9f
commit
6d77ebc84a
87725
X01Stats_20260605_093832.json
Normal file
87725
X01Stats_20260605_093832.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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();
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
32054
stats.json
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user