initial commit

This commit is contained in:
Radek Davidek 2025-12-28 18:50:44 +01:00
commit a50c03bbe6
7 changed files with 660 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
servers
target
bin
.settings
.metadata
.classpath
.project
.vscode

50
pom.xml Normal file
View File

@ -0,0 +1,50 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.darts.stats</groupId>
<artifactId>darts-stats-web</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>11</source>
<target>11</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,210 @@
package com.darts.stats;
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.stream.Collectors;
public class App {
private static final Gson GSON = new Gson();
private static final String DATA_FILE = "stats.json";
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.setExecutor(null);
System.out.println("Server started on port " + port);
server.start();
}
static class StaticHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
String path = exchange.getRequestURI().getPath();
if (path.equals("/")) path = "/index.html";
InputStream is = App.class.getResourceAsStream("/web" + path);
if (is == null) {
String response = "404 Not Found";
exchange.sendResponseHeaders(404, response.length());
OutputStream os = exchange.getResponseBody();
os.write(response.getBytes());
os.close();
return;
}
byte[] content = is.readAllBytes();
String contentType = "text/html";
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);
OutputStream os = exchange.getResponseBody();
os.write(content);
os.close();
}
}
static class UploadHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!"POST".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
return;
}
InputStream is = exchange.getRequestBody();
String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
try {
List<RawGame> rawGames = GSON.fromJson(body, new TypeToken<List<RawGame>>(){}.getType());
List<DartsGame> convertedGames = convertRawToGames(rawGames);
Files.writeString(Paths.get(DATA_FILE), GSON.toJson(convertedGames));
String response = "Upload successful";
exchange.sendResponseHeaders(200, response.length());
exchange.getResponseBody().write(response.getBytes());
} catch (Exception e) {
e.printStackTrace();
String response = "Error processing upload: " + e.getMessage();
exchange.sendResponseHeaders(400, response.length());
exchange.getResponseBody().write(response.getBytes());
} finally {
exchange.getResponseBody().close();
}
}
private List<DartsGame> convertRawToGames(List<RawGame> rawGames) {
List<DartsGame> result = new ArrayList<>();
for (RawGame rg : rawGames) {
for (RawGame.RawPlayer rp : rg.getPlayers()) {
DartsGame game = new DartsGame();
game.setDate(rg.getDate());
game.setPlayer(rp.getName());
game.setOpponent(rg.getPlayers().stream()
.filter(p -> !p.getName().equals(rp.getName()))
.map(RawGame.RawPlayer::getName)
.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);
// 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;
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;
}
game.setHundredPlus(h100);
game.setHundredFortyPlus(h140);
game.setHundredEighty(h180);
game.setHighestVisit(highest);
result.add(game);
}
}
return result;
}
}
static class StatsHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!"GET".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
return;
}
List<DartsGame> games = loadGames();
StatsResponse response = aggregateStats(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();
os.write(bytes);
os.close();
}
private List<DartsGame> loadGames() {
try {
if (!Files.exists(Paths.get(DATA_FILE))) {
return Collections.emptyList();
}
String content = Files.readString(Paths.get(DATA_FILE));
return GSON.fromJson(content, new TypeToken<List<DartsGame>>(){}.getType());
} catch (IOException e) {
e.printStackTrace();
return Collections.emptyList();
}
}
private StatsResponse aggregateStats(List<DartsGame> games) {
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();
int h140 = playerGames.stream().mapToInt(DartsGame::getHundredFortyPlus).sum();
int h180 = playerGames.stream().mapToInt(DartsGame::getHundredEighty).sum();
playerStatsMap.put(player, StatsResponse.PlayerStats.builder()
.overallAverage(Math.round(avg * 100.0) / 100.0)
.totalGames(playerGames.size())
.wins(wins)
.winRate(Math.round((double) wins / playerGames.size() * 10000.0) / 100.0)
.total100plus(h100)
.total140plus(h140)
.total180s(h180)
.build());
}
return StatsResponse.builder()
.games(games)
.playerStats(playerStatsMap)
.build();
}
}
}

View File

@ -0,0 +1,23 @@
package com.darts.stats;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DartsGame {
private String date;
private String player;
private String opponent;
private String gameType;
private String result;
private int dartsThrown;
private double average;
private int checkout;
private int highestVisit;
private int hundredPlus;
private int hundredFortyPlus;
private int hundredEighty;
}

View File

@ -0,0 +1,25 @@
package com.darts.stats;
import lombok.Data;
import java.util.List;
@Data
public class RawGame {
private String date;
private List<RawPlayer> players;
private String winnerName;
private int targetScore;
@Data
public static class RawPlayer {
private String name;
private List<RawThrow> throwsList;
}
@Data
public static class RawThrow {
private int value;
private boolean isBust;
private String label;
}
}

View File

@ -0,0 +1,25 @@
package com.darts.stats;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class StatsResponse {
private List<DartsGame> games;
private Map<String, PlayerStats> playerStats;
@Data
@Builder
public static class PlayerStats {
private double overallAverage;
private int totalGames;
private int wins;
private int total100plus;
private int total140plus;
private int total180s;
private double winRate;
}
}

View File

@ -0,0 +1,319 @@
<!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: #2563eb;
--bg: #f8fafc;
--card-bg: #ffffff;
--text-main: #1e293b;
--text-muted: #64748b;
--border: #e2e8f0;
}
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; }
.card {
background: var(--card-bg);
padding: 24px;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
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: #eff6ff;
border: 1px dashed var(--primary);
}
input[type="file"] {
font-size: 0.9rem;
}
button {
background: var(--primary);
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
button:hover { opacity: 0.9; }
.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.25rem;
font-weight: 700;
border-bottom: 2px solid var(--bg);
padding-bottom: 8px;
color: var(--primary);
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.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: var(--bg);
padding: 12px;
text-align: left;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
user-select: none;
transition: background 0.2s;
}
th:hover { background-color: var(--border); }
td { padding: 12px; border-bottom: 1px solid var(--border); }
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: #dcfce7; color: #166534; }
.badge-loss { background: #fee2e2; color: #991b1b; }
.chart-container { position: relative; height: 350px; width: 100%; }
</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>
<input type="file" id="fileInput" accept=".json">
<button onclick="uploadFile()">Upload</button>
</div>
<span id="uploadStatus" style="font-weight: 600"></span>
</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>
<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>
<script>
async function uploadFile() {
const fileInput = document.getElementById('fileInput');
const status = document.getElementById('uploadStatus');
if (!fileInput.files[0]) {
status.innerText = 'Please select a file';
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
});
if (response.ok) {
status.innerText = 'Upload successful!';
loadStats();
} else {
status.innerText = 'Upload failed: ' + await response.text();
}
} catch (err) {
status.innerText = 'Error: ' + err.message;
}
};
reader.readAsText(file);
}
async function loadStats() {
const response = await fetch('/api/stats');
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 => {
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;
});
return {
label: player,
data: data,
borderColor: '#' + Math.floor(Math.random()*16777215).toString(16),
fill: false,
tension: 0.1,
spanGaps: true
};
});
window.myChart = new Chart(ctx, {
type: 'line',
data: {
labels: dates,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: false, title: { display: true, text: '3-Dart Average' } }
}
}
});
}
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>
</body>
</html>