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;
|
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.Gson;
|
||||||
import com.google.gson.reflect.TypeToken;
|
import com.google.gson.reflect.TypeToken;
|
||||||
import com.sun.net.httpserver.HttpExchange;
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
import com.sun.net.httpserver.HttpHandler;
|
import com.sun.net.httpserver.HttpHandler;
|
||||||
import com.sun.net.httpserver.HttpServer;
|
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 {
|
public class App {
|
||||||
private static final Gson GSON = new Gson();
|
private static final Gson GSON = new Gson();
|
||||||
private static final Map<String, CachedData> GAMES_CACHE = new ConcurrentHashMap<>();
|
private static final Map<String, CachedData> GAMES_CACHE = new ConcurrentHashMap<>();
|
||||||
@ -26,10 +32,12 @@ public class App {
|
|||||||
|
|
||||||
static class CachedData {
|
static class CachedData {
|
||||||
final List<DartsGame> games;
|
final List<DartsGame> games;
|
||||||
|
final List<RawGame> rawGames;
|
||||||
final long expiryTime;
|
final long expiryTime;
|
||||||
|
|
||||||
CachedData(List<DartsGame> games) {
|
CachedData(List<DartsGame> games, List<RawGame> rawGames) {
|
||||||
this.games = games;
|
this.games = games;
|
||||||
|
this.rawGames = rawGames;
|
||||||
this.expiryTime = System.currentTimeMillis() + TTL_MS;
|
this.expiryTime = System.currentTimeMillis() + TTL_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,7 +69,8 @@ public class App {
|
|||||||
@Override
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
String path = exchange.getRequestURI().getPath();
|
String path = exchange.getRequestURI().getPath();
|
||||||
if (path.equals("/")) path = "/index.html";
|
if (path.equals("/"))
|
||||||
|
path = "/index.html";
|
||||||
|
|
||||||
InputStream is = App.class.getResourceAsStream("/web" + path);
|
InputStream is = App.class.getResourceAsStream("/web" + path);
|
||||||
if (is == null) {
|
if (is == null) {
|
||||||
@ -75,8 +84,10 @@ public class App {
|
|||||||
|
|
||||||
byte[] content = is.readAllBytes();
|
byte[] content = is.readAllBytes();
|
||||||
String contentType = "text/html";
|
String contentType = "text/html";
|
||||||
if (path.endsWith(".js")) contentType = "application/javascript";
|
if (path.endsWith(".js"))
|
||||||
if (path.endsWith(".css")) contentType = "text/css";
|
contentType = "application/javascript";
|
||||||
|
if (path.endsWith(".css"))
|
||||||
|
contentType = "text/css";
|
||||||
|
|
||||||
exchange.getResponseHeaders().set("Content-Type", contentType);
|
exchange.getResponseHeaders().set("Content-Type", contentType);
|
||||||
exchange.sendResponseHeaders(200, content.length);
|
exchange.sendResponseHeaders(200, content.length);
|
||||||
@ -125,11 +136,12 @@ public class App {
|
|||||||
String body = new String(bytes, StandardCharsets.UTF_8);
|
String body = new String(bytes, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
try {
|
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);
|
List<DartsGame> convertedGames = convertRawToGames(rawGames);
|
||||||
|
|
||||||
String code = generateCode();
|
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.getResponseHeaders().set("Location", "/?code=" + code);
|
||||||
exchange.sendResponseHeaders(303, -1);
|
exchange.sendResponseHeaders(303, -1);
|
||||||
@ -169,8 +181,10 @@ public class App {
|
|||||||
List<RawGame.RawThrow> throwsList = rp.getThrowsList();
|
List<RawGame.RawThrow> throwsList = rp.getThrowsList();
|
||||||
game.setDartsThrown(throwsList.size());
|
game.setDartsThrown(throwsList.size());
|
||||||
|
|
||||||
int totalScore = throwsList.stream().filter(t -> !t.isBust()).mapToInt(RawGame.RawThrow::getValue).sum();
|
int totalScore = throwsList.stream().filter(t -> !t.isBust()).mapToInt(RawGame.RawThrow::getValue)
|
||||||
game.setAverage(throwsList.isEmpty() ? 0 : Math.round((double) totalScore / throwsList.size() * 3 * 100.0) / 100.0);
|
.sum();
|
||||||
|
game.setAverage(throwsList.isEmpty() ? 0
|
||||||
|
: Math.round((double) totalScore / throwsList.size() * 3 * 100.0) / 100.0);
|
||||||
|
|
||||||
// Calculate visits
|
// Calculate visits
|
||||||
int h100 = 0, h140 = 0, h180 = 0, highest = 0;
|
int h100 = 0, h140 = 0, h180 = 0, highest = 0;
|
||||||
@ -178,16 +192,22 @@ public class App {
|
|||||||
int visitSum = 0;
|
int visitSum = 0;
|
||||||
boolean bust = false;
|
boolean bust = false;
|
||||||
for (int j = 0; j < 3 && (i + j) < throwsList.size(); j++) {
|
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();
|
visitSum += throwsList.get(i + j).getValue();
|
||||||
}
|
}
|
||||||
if (bust) visitSum = 0;
|
if (bust)
|
||||||
|
visitSum = 0;
|
||||||
|
|
||||||
if (visitSum >= 180) h180++;
|
if (visitSum >= 180)
|
||||||
else if (visitSum >= 140) h140++;
|
h180++;
|
||||||
else if (visitSum >= 100) h100++;
|
else if (visitSum >= 140)
|
||||||
|
h140++;
|
||||||
|
else if (visitSum >= 100)
|
||||||
|
h100++;
|
||||||
|
|
||||||
if (visitSum > highest) highest = visitSum;
|
if (visitSum > highest)
|
||||||
|
highest = visitSum;
|
||||||
}
|
}
|
||||||
|
|
||||||
game.setHundredPlus(h100);
|
game.setHundredPlus(h100);
|
||||||
@ -201,6 +221,7 @@ public class App {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void sendError(HttpExchange exchange, int statusCode, String message) throws IOException {
|
private static void sendError(HttpExchange exchange, int statusCode, String message) throws IOException {
|
||||||
byte[] responseBytes = message.getBytes(StandardCharsets.UTF_8);
|
byte[] responseBytes = message.getBytes(StandardCharsets.UTF_8);
|
||||||
exchange.sendResponseHeaders(statusCode, responseBytes.length);
|
exchange.sendResponseHeaders(statusCode, responseBytes.length);
|
||||||
@ -208,6 +229,7 @@ public class App {
|
|||||||
os.write(responseBytes);
|
os.write(responseBytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static class StatsHandler implements HttpHandler {
|
static class StatsHandler implements HttpHandler {
|
||||||
@Override
|
@Override
|
||||||
public void handle(HttpExchange exchange) throws IOException {
|
public void handle(HttpExchange exchange) throws IOException {
|
||||||
@ -221,8 +243,10 @@ public class App {
|
|||||||
|
|
||||||
CachedData cached = (code != null) ? GAMES_CACHE.get(code.toUpperCase()) : null;
|
CachedData cached = (code != null) ? GAMES_CACHE.get(code.toUpperCase()) : null;
|
||||||
List<DartsGame> games = (cached != null && !cached.isExpired()) ? cached.games : Collections.emptyList();
|
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));
|
StatsResponse response = aggregateStats(new ArrayList<>(games), new ArrayList<>(rawGames));
|
||||||
|
|
||||||
String json = GSON.toJson(response);
|
String json = GSON.toJson(response);
|
||||||
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
|
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
|
||||||
@ -235,7 +259,8 @@ public class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, String> parseQuery(String query) {
|
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<>();
|
Map<String, String> result = new HashMap<>();
|
||||||
for (String param : query.split("&")) {
|
for (String param : query.split("&")) {
|
||||||
String[] entry = param.split("=");
|
String[] entry = param.split("=");
|
||||||
@ -246,7 +271,7 @@ public class App {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private StatsResponse aggregateStats(List<DartsGame> games) {
|
private StatsResponse aggregateStats(List<DartsGame> games, List<RawGame> rawGames) {
|
||||||
Map<String, List<DartsGame>> byPlayer = games.stream()
|
Map<String, List<DartsGame>> byPlayer = games.stream()
|
||||||
.collect(Collectors.groupingBy(DartsGame::getPlayer));
|
.collect(Collectors.groupingBy(DartsGame::getPlayer));
|
||||||
|
|
||||||
@ -275,6 +300,7 @@ public class App {
|
|||||||
|
|
||||||
return StatsResponse.builder()
|
return StatsResponse.builder()
|
||||||
.games(games)
|
.games(games)
|
||||||
|
.rawGames(rawGames)
|
||||||
.playerStats(playerStatsMap)
|
.playerStats(playerStatsMap)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import java.util.Map;
|
|||||||
@Builder
|
@Builder
|
||||||
public class StatsResponse {
|
public class StatsResponse {
|
||||||
private List<DartsGame> games;
|
private List<DartsGame> games;
|
||||||
|
private List<RawGame> rawGames;
|
||||||
private Map<String, PlayerStats> playerStats;
|
private Map<String, PlayerStats> playerStats;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|||||||
@ -43,6 +43,34 @@
|
|||||||
background: #14532d33; /* Dark green tint */
|
background: #14532d33; /* Dark green tint */
|
||||||
border: 1px dashed var(--primary);
|
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"] {
|
input[type="file"] {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@ -145,6 +173,172 @@
|
|||||||
}
|
}
|
||||||
.badge-win { background: #064e3b; color: #34d399; }
|
.badge-win { background: #064e3b; color: #34d399; }
|
||||||
.badge-loss { background: #7f1d1d; color: #fca5a5; }
|
.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%; }
|
.chart-container { position: relative; height: 350px; width: 100%; }
|
||||||
|
|
||||||
@ -159,6 +353,12 @@
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.filter-bar {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.filter-control {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@ -176,6 +376,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chart-container { height: 250px; }
|
.chart-container { height: 250px; }
|
||||||
|
.modal {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.modal-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
@ -218,6 +424,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 id="player-summaries" class="stats-grid"></div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@ -234,12 +453,13 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th onclick="sortTable(0)">Date ↕</th>
|
<th onclick="sortTable(0)">Date ↕</th>
|
||||||
<th onclick="sortTable(1)">Player ↕</th>
|
<th onclick="sortTable(1)">Game # ↕</th>
|
||||||
<th onclick="sortTable(2)">Opponent ↕</th>
|
<th onclick="sortTable(2)">Player ↕</th>
|
||||||
<th onclick="sortTable(3)">Avg ↕</th>
|
<th onclick="sortTable(3)">Opponent ↕</th>
|
||||||
<th onclick="sortTable(4)">Darts ↕</th>
|
<th onclick="sortTable(4)">Avg ↕</th>
|
||||||
<th onclick="sortTable(5)">Round ↕</th>
|
<th onclick="sortTable(5)">Darts ↕</th>
|
||||||
<th onclick="sortTable(6)">Result ↕</th>
|
<th onclick="sortTable(6)">Round ↕</th>
|
||||||
|
<th onclick="sortTable(7)">Result ↕</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
@ -249,8 +469,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="notification"></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>
|
<script>
|
||||||
|
let allGames = [];
|
||||||
|
let allRawGames = [];
|
||||||
|
let selectedDay = '__all__';
|
||||||
|
|
||||||
function showNotification(message, isError = false) {
|
function showNotification(message, isError = false) {
|
||||||
const nav = document.getElementById('notification');
|
const nav = document.getElementById('notification');
|
||||||
nav.innerText = message;
|
nav.innerText = message;
|
||||||
@ -333,9 +569,67 @@
|
|||||||
const response = await fetch(`/api/stats${code ? `?code=${code}` : ''}`);
|
const response = await fetch(`/api/stats${code ? `?code=${code}` : ''}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
renderSummaries(data.playerStats);
|
allGames = data.games || [];
|
||||||
renderChart(data.games);
|
allRawGames = data.rawGames || [];
|
||||||
renderTable(data.games);
|
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) {
|
function renderSummaries(playerStats) {
|
||||||
@ -343,17 +637,19 @@
|
|||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
for (const [player, stats] of Object.entries(playerStats)) {
|
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');
|
const card = document.createElement('div');
|
||||||
card.className = 'card player-card';
|
card.className = 'card player-card';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="player-header">${player}</div>
|
<div class="player-header">${player}</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Overall Average</span>
|
<span class="stat-label">Overall Average</span>
|
||||||
<span class="stat-value">${stats.overallAverage}</span>
|
<span class="stat-value">${overallAverage}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Win Rate</span>
|
<span class="stat-label">Win Rate</span>
|
||||||
<span class="stat-value">${stats.winRate}%</span>
|
<span class="stat-value">${winRate}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Total 180s</span>
|
<span class="stat-label">Total 180s</span>
|
||||||
@ -371,6 +667,9 @@
|
|||||||
function renderChart(games) {
|
function renderChart(games) {
|
||||||
const ctx = document.getElementById('avgChart').getContext('2d');
|
const ctx = document.getElementById('avgChart').getContext('2d');
|
||||||
if (window.myChart) window.myChart.destroy();
|
if (window.myChart) window.myChart.destroy();
|
||||||
|
if (!games.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const players = [...new Set(games.map(g => g.player))];
|
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 dates = [...new Set(games.map(g => g.date))].sort((a, b) => new Date(a) - new Date(b));
|
||||||
@ -429,13 +728,16 @@
|
|||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
const sortedGames = games.slice().sort((a, b) => new Date(b.date) - new Date(a.date));
|
const sortedGames = games.slice().sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||||
|
const gameOrderByTimestamp = buildGameOrderMap(games);
|
||||||
|
|
||||||
sortedGames.forEach(game => {
|
sortedGames.forEach(game => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
const resultClass = game.result === 'WIN' ? 'badge-win' : 'badge-loss';
|
const resultClass = game.result === 'WIN' ? 'badge-win' : 'badge-loss';
|
||||||
const round = Math.ceil(game.dartsThrown / 3);
|
const round = Math.ceil(game.dartsThrown / 3);
|
||||||
|
const gameOrder = gameOrderByTimestamp.get(game.date) || '-';
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td style="font-weight: 500">${game.date}</td>
|
<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 style="font-weight: 600">${game.player}</td>
|
||||||
<td>${game.opponent}</td>
|
<td>${game.opponent}</td>
|
||||||
<td style="font-weight: 700; color: var(--primary)">${game.average}</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) {
|
function sortTable(n) {
|
||||||
const table = document.getElementById("games-table");
|
const table = document.getElementById("games-table");
|
||||||
const tbody = table.querySelector("tbody");
|
const tbody = table.querySelector("tbody");
|
||||||
@ -458,7 +923,7 @@
|
|||||||
let x = a.cells[n].textContent.trim().toLowerCase();
|
let x = a.cells[n].textContent.trim().toLowerCase();
|
||||||
let y = b.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);
|
return dir === "asc" ? parseFloat(x) - parseFloat(y) : parseFloat(y) - parseFloat(x);
|
||||||
}
|
}
|
||||||
if (n === 0) { // Date
|
if (n === 0) { // Date
|
||||||
@ -474,6 +939,23 @@
|
|||||||
rows.forEach(row => tbody.appendChild(row));
|
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();
|
loadStats();
|
||||||
</script>
|
</script>
|
||||||
<footer>
|
<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