diff --git a/app.js b/app.js
index fe4e881..6e6d637 100644
--- a/app.js
+++ b/app.js
@@ -3,37 +3,40 @@
* Haupt-Application
*/
-const PGN_URL = 'https://www.deutsche-schachjugend.de/2026/odjm-d/partien/gesamt-utf8.pgn';
-const REFRESH_INTERVAL = 10000; // 10 Sekunden
const PLAYER_NAME = 'Kiesewetter, Lara';
let board = null;
let chess = null;
let currentGame = null;
let allLaraGames = [];
-let refreshTimer = null;
-let countdown = 0;
let serverLastFetch = null;
let laraColor = null;
let currentMoveIndex = -1;
let userSelectedGame = false;
let userScrolledMoves = false;
+let lastMtime = 0;
+let pollId = 0;
/**
* Lädt die PGN-Datei und aktualisiert die Anzeige
*/
async function loadPGN(showOverlay = true) {
+ const currentPollId = ++pollId;
if (showOverlay) showLoading(true);
hideError();
try {
const [pgnResponse, statusResponse] = await Promise.all([
- fetch('http://localhost:8111/pgn'),
+ fetch(`http://localhost:8111/pgn?since=${lastMtime}`),
fetch('http://localhost:8111/status').catch(() => null)
]);
+ if (currentPollId !== pollId) return;
if (!pgnResponse.ok) throw new Error(`HTTP ${pgnResponse.status}`);
+ const mtimeHeader = pgnResponse.headers.get('X-Cache-Mtime');
+ if (mtimeHeader) lastMtime = parseFloat(mtimeHeader);
+
if (statusResponse && statusResponse.ok) {
const status = await statusResponse.json();
serverLastFetch = status.last_fetch ? status.last_fetch * 1000 : null;
@@ -62,10 +65,12 @@ async function loadPGN(showOverlay = true) {
updateMovesList();
updateAllGamesList();
updateTimestamp();
+ updateStandings();
showLoading(false);
} catch (error) {
+ if (currentPollId !== pollId) return;
console.error('Fehler beim Laden:', error);
showError(`Fehler: ${error.message}`);
showLoading(false);
@@ -367,6 +372,49 @@ function updateAllGamesList() {
}
}
+/**
+ * Lädt die Turniertabelle vom Proxy und zeigt Laras Platzierung an
+ */
+function updateStandings() {
+ fetch('http://localhost:8111/standings')
+ .then(res => {
+ if (!res.ok) throw new Error('Fehler beim Laden');
+ return res.json();
+ })
+ .then(data => {
+ const container = document.getElementById('standings-content');
+ if (!data || data.error) {
+ container.innerHTML = '
Daten nicht verfügbar
';
+ return;
+ }
+ container.innerHTML = `
+ ${data.rank}.
+ Tabellenplatz
+ ${data.round_info || 'nach Runde 1'}
+
+ Punkte
+ ${data.points}
+
+
+ Siege
+ ${data.wins}
+
+
+ Unentschieden
+ ${data.draws}
+
+
+ Niederlagen
+ ${data.losses}
+
+ `;
+ })
+ .catch(err => {
+ document.getElementById('standings-content').innerHTML =
+ 'Daten nicht verfügbar
';
+ });
+}
+
/**
* Format clock string
*/
@@ -386,25 +434,19 @@ function updateTimestamp() {
}
/**
- * Start auto-refresh
+ * Start long-polling: nach jeder Antwort sofort die nächste Anfrage stellen
*/
function startAutoRefresh() {
- countdown = REFRESH_INTERVAL / 1000;
+ document.getElementById('refresh-timer').textContent = '● Live';
+ document.getElementById('refresh-timer').style.color = '#4ade80';
- if (refreshTimer) clearInterval(refreshTimer);
-
- refreshTimer = setInterval(() => {
- countdown--;
- const mins = Math.floor(countdown / 60);
- const secs = countdown % 60;
- document.getElementById('refresh-timer').textContent =
- `Nächstes Update in: ${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
-
- if (countdown <= 0) {
- countdown = REFRESH_INTERVAL / 1000;
- loadPGN(false);
+ async function poll() {
+ while (true) {
+ await loadPGN(false);
+ await new Promise(r => setTimeout(r, 30000));
}
- }, 1000);
+ }
+ poll();
}
/**
@@ -424,11 +466,11 @@ function hideError() {
}
/**
- * Manual refresh button
+ * Manual refresh button – startet neuen Long-Poll-Zyklus
*/
document.getElementById('refresh-btn').addEventListener('click', () => {
- countdown = REFRESH_INTERVAL / 1000;
- loadPGN();
+ pollId++;
+ loadPGN(true);
});
/**
diff --git a/index.html b/index.html
index ead35b9..bb57fed 100644
--- a/index.html
+++ b/index.html
@@ -54,6 +54,12 @@
Alle Partien von Lara
+
+
Turniertabelle ODJM D 2026
+
+
Lade Tabellenstand...
+
+
diff --git a/server.py b/server.py
index 0f99bec..0e2a8d8 100644
--- a/server.py
+++ b/server.py
@@ -7,30 +7,38 @@ Lädt die PGN-Datei und stellt sie mit CORS-Headern bereit.
import http.server
import socketserver
import urllib.request
+import urllib.parse
import sys
import os
import threading
import time
import json
+import re
from datetime import datetime
PGN_URL = os.environ.get("PGN_URL", "https://www.deutsche-schachjugend.de/2026/odjm-d/partien/gesamt-utf8.pgn")
+STANDINGS_URL = "https://www.deutsche-schachjugend.de/2026/odjm-d/tabelle/"
PORT = int(os.environ.get("PORT", 8111))
CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache")
CACHE_FILE = os.path.join(CACHE_DIR, "gesamt-utf8.pgn")
-CACHE_TTL = int(os.environ.get("CACHE_TTL", 30)) # Sekunden
+STANDINGS_CACHE_FILE = os.path.join(CACHE_DIR, "standings.json")
+CACHE_TTL = int(os.environ.get("CACHE_TTL", 31)) # Sekunden
os.makedirs(CACHE_DIR, exist_ok=True)
last_fetch_time = None
+last_standings_fetch_time = None
def fetch_pgn():
"""Lädt die PGN-Datei von der URL als Bytes."""
global last_fetch_time
try:
- req = urllib.request.Request(PGN_URL, headers={"User-Agent": "Mozilla/5.0"})
- with urllib.request.urlopen(req, timeout=30) as response:
+ headers = {"User-Agent": "Mozilla/5.0"}
+ if last_fetch_time:
+ headers["modified_since"] = str(last_fetch_time)
+ req = urllib.request.Request(PGN_URL, headers=headers)
+ with urllib.request.urlopen(req, timeout=31) as response:
data = response.read()
last_fetch_time = time.time()
return data
@@ -39,43 +47,117 @@ def fetch_pgn():
return None
-def get_pgn_content():
- """Gibt PGN-Inhalt als Bytes zurück, nutzt Cache wenn möglich."""
- now = time.time()
-
- # Prüfe Cache
+def get_pgn_content_longpoll(since=0):
+ """Long-Poll: Gibt PGN-Inhalt + mtime zurück.
+ Wenn Cache seit `since` unverändert ist, wird bis zu 31s gewartet."""
+ deadline = time.time() + 31
+
+ while time.time() < deadline:
+ if os.path.exists(CACHE_FILE):
+ mtime = os.path.getmtime(CACHE_FILE)
+ if mtime > since:
+ with open(CACHE_FILE, "rb") as f:
+ return f.read(), mtime
+ age = time.time() - mtime
+ if age >= CACHE_TTL:
+ content = fetch_pgn()
+ if content:
+ with open(CACHE_FILE, "wb") as f:
+ f.write(content)
+ new_mtime = os.path.getmtime(CACHE_FILE)
+ if new_mtime > since:
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] PGN via Long-Poll aktualisiert ({len(content)} Bytes)")
+ return content, new_mtime
+ else:
+ content = fetch_pgn()
+ if content:
+ with open(CACHE_FILE, "wb") as f:
+ f.write(content)
+ return content, os.path.getmtime(CACHE_FILE)
+
+ time.sleep(2)
+
+ # Timeout – aktuellen Stand zurückgeben
if os.path.exists(CACHE_FILE):
- age = now - os.path.getmtime(CACHE_FILE)
- if age < CACHE_TTL:
- with open(CACHE_FILE, "rb") as f:
- return f.read()
-
- # Cache verfallen oder nicht vorhanden -> neu laden
- content = fetch_pgn()
- if content:
- with open(CACHE_FILE, "wb") as f:
- f.write(content)
- print(f"[{datetime.now().strftime('%H:%M:%S')}] PGN aktualisiert ({len(content)} Bytes)")
- return content
-
- # Fallback: alter Cache
- if os.path.exists(CACHE_FILE):
- print(f"[{datetime.now().strftime('%H:%M:%S')}] Verwende alten Cache")
with open(CACHE_FILE, "rb") as f:
- return f.read()
-
+ return f.read(), os.path.getmtime(CACHE_FILE)
+ return None, 0
+
+
+def fetch_standings():
+ """Lädt die Tabellenseite und parst Laras Platzierung."""
+ global last_standings_fetch_time
+ try:
+ headers = {"User-Agent": "Mozilla/5.0"}
+ if last_standings_fetch_time:
+ headers["modified_since"] = str(last_standings_fetch_time)
+ req = urllib.request.Request(STANDINGS_URL, headers=headers)
+ with urllib.request.urlopen(req, timeout=31) as response:
+ html = response.read().decode("utf-8", errors="replace")
+ last_standings_fetch_time = time.time()
+
+ # Rundeninfo extrahieren
+ round_info = ""
+ m = re.search(r"Tabellenstand\s+nach\s+der\s+(\d+)\.\s*Runde", html)
+ if m:
+ round_info = f"nach der {m.group(1)}. Runde"
+
+ # Tabellenzeilen suchen
+ rows = re.findall(r"]*>(.*?)
", html, re.DOTALL | re.IGNORECASE)
+ for row in rows:
+ if "Lara Kiesewetter" not in row:
+ continue
+ cells = re.findall(r"]*>(.*?) | ", row, re.DOTALL)
+ clean = [re.sub(r"<[^>]+>", "", c).strip() for c in cells]
+ if len(clean) >= 9:
+ return {
+ "rank": clean[0],
+ "player": "Lara Kiesewetter",
+ "wins": clean[5],
+ "draws": clean[6],
+ "losses": clean[7],
+ "points": clean[8],
+ "round_info": round_info,
+ }
+ return None
+ except Exception as e:
+ print(f"Fehler beim Laden der Tabelle: {e}")
+ return None
+
+
+def get_standings_content():
+ """Gibt Tabellenstand als Dict zurück, nutzt Cache."""
+ now = time.time()
+
+ if os.path.exists(STANDINGS_CACHE_FILE):
+ age = now - os.path.getmtime(STANDINGS_CACHE_FILE)
+ if age < 300: # 5 Minuten Cache
+ with open(STANDINGS_CACHE_FILE, "r") as f:
+ return json.load(f)
+
+ data = fetch_standings()
+ if data:
+ with open(STANDINGS_CACHE_FILE, "w") as f:
+ json.dump(data, f)
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] Tabellenstand aktualisiert")
+ return data
return None
class PGNHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
- if self.path == "/pgn":
- content = get_pgn_content()
+ parsed_path = urllib.parse.urlparse(self.path)
+
+ if parsed_path.path == "/pgn":
+ params = urllib.parse.parse_qs(parsed_path.query)
+ since = float(params.get("since", [0])[0])
+ content, mtime = get_pgn_content_longpoll(since)
if content:
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
+ self.send_header("X-Cache-Mtime", str(mtime))
self.end_headers()
self.wfile.write(content)
else:
@@ -83,9 +165,9 @@ class PGNHandler(http.server.BaseHTTPRequestHandler):
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
- self.wfile.write(b'{"error": "Konnte PGN nicht laden"}')
+ self.wfile.write(json.dumps({"error": "Konnte PGN nicht laden"}).encode())
- elif self.path == "/status":
+ elif parsed_path.path == "/status":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
@@ -99,6 +181,23 @@ class PGNHandler(http.server.BaseHTTPRequestHandler):
}
self.wfile.write(json.dumps(status).encode())
+ elif parsed_path.path == "/standings":
+ data = get_standings_content()
+ if data:
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Cache-Control", "max-age=300")
+ self.end_headers()
+ self.wfile.write(json.dumps(data).encode())
+ else:
+ self.send_response(502)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ err = {"error": "Tabellendaten nicht verf\u00fcgbar"}
+ self.wfile.write(json.dumps(err).encode())
+
else:
# Statische Dateien aus dem Verzeichnis
if self.path == "/":
diff --git a/style.css b/style.css
index a229922..46a071a 100644
--- a/style.css
+++ b/style.css
@@ -168,18 +168,69 @@ header h1 {
border-radius: 8px;
}
-#moves-panel, #all-games-panel {
+#moves-panel, #all-games-panel, #standings-panel {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 16px;
}
-#moves-panel h3, #all-games-panel h3 {
+#moves-panel h3, #all-games-panel h3, #standings-panel h3 {
margin-bottom: 12px;
color: #e94560;
font-size: 1rem;
}
+#standings-content {
+ font-size: 0.9rem;
+ line-height: 1.6;
+}
+
+#standings-content .standings-loading {
+ color: #888;
+ font-style: italic;
+}
+
+.standings-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 6px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.standings-row:last-child {
+ border-bottom: none;
+}
+
+.standings-label {
+ color: #aaa;
+}
+
+.standings-value {
+ color: #fff;
+ font-weight: 600;
+}
+
+.standings-rank {
+ font-size: 2rem;
+ font-weight: bold;
+ color: #e94560;
+ text-align: center;
+ padding: 8px 0;
+}
+
+.standings-rank-label {
+ font-size: 0.8rem;
+ color: #888;
+ text-align: center;
+}
+
+.standings-header {
+ text-align: center;
+ margin-bottom: 8px;
+ color: #ffd700;
+ font-size: 0.85rem;
+}
+
#moves-list {
max-height: 300px;
overflow-y: auto;