initial commit

This commit is contained in:
Marc Wieland 2026-01-07 23:42:36 +01:00
commit f67d869005
11 changed files with 2403 additions and 0 deletions

16
.dockerignore Normal file
View File

@ -0,0 +1,16 @@
# Git
.git
.gitignore
.github
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# IDE
.vscode
.idea
# Dokumentation (optional - auskommentieren falls benötigt)
# README.md

112
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,112 @@
# Copilot Instructions for Turnierplaner
## Project Overview
**Turnierplaner** (Tournament Planner) is a German-language, client-side volleyball tournament management app for the "NVJ" organization. It handles team registration, round-robin rotation logic, live scoring, and leaderboards for two leagues: "Bundesliga" and "Champions League".
### Tech Stack
- **Frontend**: Vanilla HTML, CSS, JavaScript (ES6+)
- **Persistence**: LocalStorage for all data (no backend)
- **UI Pattern**: Multi-page app with shared state via localStorage
- **Build**: None - runs directly in browser via file:// or static server
## Architecture & Key Components
### Two-Page Structure
1. **[index.html](index.html)** + [script.js](script.js): Team entry interface
- Add/remove teams with name, club, and motto fields
- Set field count (used to calculate fields per league)
- Export/import tournament configuration as JSON
- Navigate to planning view with "Weiter →"
2. **[planning.html](planning.html)** + [planning.js](planning.js): Live tournament management
- Display current round matchups on fields (grid layout: field number | team 1 | team 2)
- Show waiting teams below the playing fields
- Live score input per match with automatic point calculation
- Round rotation system with "Nächste Runde" button
- Timer with custom input (MM:SS or seconds)
- Modals for: Points History, Scoreboard (rankings), Reset confirmation
### Data Flow & State Management
**Three localStorage Keys** ([planning.js](planning.js#L1-L3)):
- `turnierplaner_data`: Teams + field count (shared between pages)
- `turnierplaner_rotation`: Current round number + team order per league
- `turnierplaner_scores`: Match results keyed by `"league:fieldNum"`
**Critical Logic**: Field allocation in [planning.js](planning.js#L63-L76)
```javascript
// fieldCount = number of physical fields (from user input)
// Each field has 2 halves, so max playing teams = fieldCount * 2
const maxPlayingTeams = fieldCount * 2;
const playingTeamsCount = Math.min(teams.length, maxPlayingTeams);
const fieldsPerLeague = Math.ceil(playingTeamsCount / 2);
```
**Round Rotation** ([planning.js](planning.js#L574-L611)): Three rotation strategies based on waiting team count:
- **Fall 1** (0 waiting, teams === fieldCount * 2): Team at index 0 stays, team at index 1 moves to end
- **Fall 2** (1 waiting, teams === fieldCount * 2 + 1): All rotate by 1, first team goes to end
- **Fall 3** (2+ waiting, teams > fieldCount * 2 + 1): Swap playing/waiting blocks entirely
**Point Calculation** ([planning.js](planning.js#L544-L565)): Winner gets `|score1 - score2| + 2`, loser gets `-|score1 - score2|`, tie = 0
## Development Workflows
### Running Locally
```bash
# No build step - open directly in browser
start index.html # Windows
# Or use a local server for better CORS handling
npx serve .
```
### Testing Changes
1. Open browser DevTools → Application → Local Storage
2. Clear `turnierplaner_*` keys to reset state
3. Use Export/Import JSON to preserve test data
### Debugging Rotation Logic
Add breakpoints in [planning.js](planning.js#L574) `rotateLeague()`. Check `rotationState.teamOrder` vs actual team indices.
## Code Conventions & Patterns
### Naming & Structure
- German UI text (labels, buttons, alerts), English code (variables, functions)
- Global functions (no modules): `onclick="functionName()"` in HTML
- Modal pattern: `open{Name}Modal()` / `close{Name}Modal()` with `display: block/none`
- Auto-save: Input event listeners trigger `saveData()` ([script.js](script.js#L94-L99))
### Score Input Pattern ([planning.js](planning.js#L116-L120))
```javascript
// Inline onchange in field card HTML
onchange="updateMatchScore('bundesliga', 1, this.value, otherInput.value)"
```
### XSS Prevention
Use `escapeHtml()` ([script.js](script.js#L203-L210)) for user input in team names/clubs/mottos
### Mobile-First CSS ([styles.css](styles.css))
- `viewport-fit=cover` for iOS safe areas
- Touch-optimized: `min-height: 44px`, `touch-action: manipulation`
- Responsive grid: `grid-template-columns: 1fr 1fr``1fr` on mobile
## Integration Points & Dependencies
### External Dependencies
None - pure vanilla JavaScript
### Browser APIs
- **LocalStorage**: All persistence ([script.js](script.js#L1), [planning.js](planning.js#L1-L3))
- **File API**: JSON import via `<input type="file">` ([script.js](script.js#L180-L200))
- **Blob API**: JSON export download ([script.js](script.js#L166-L177))
### Cross-Page Communication
Exclusively via localStorage keys. [index.html](index.html) writes `turnierplaner_data`, [planning.html](planning.html) reads it on load ([planning.js](planning.js#L12-L14)).
## Important Gotchas
1. **Field Count Logic**: `fieldCount` from user input = physical fields. Each field has 2 halves, so max `playingTeamsCount = fieldCount * 2`
2. **Field Numbering Offset**: Champions League fields start after Bundesliga fields ([planning.js](planning.js#L65-L69))
3. **Round State Mismatch**: If `rotationState` deleted but `matchScores` exists, scores reference wrong teams
4. **Timer Doesn't Persist**: Timer state (`timerSeconds`, `isRunning`) not saved to localStorage
5. **Deletion Side Effects**: Deleting teams in [index.html](index.html) doesn't invalidate [planning.html](planning.html) rotation state

101
DOCKER.md Normal file
View File

@ -0,0 +1,101 @@
# Turnierplaner - Docker Deployment
## Docker Setup
Die Anwendung ist als statischer Container mit nginx konfiguriert.
### Voraussetzungen
- Docker Desktop für Windows installiert und gestartet
- Docker Compose (ist in Docker Desktop enthalten)
### Anwendung starten
**Option 1: Mit Docker Compose (empfohlen)**
```powershell
# Container bauen und starten
docker-compose up -d
# Logs anzeigen
docker-compose logs -f
# Container stoppen
docker-compose down
```
**Option 2: Mit Docker direkt**
```powershell
# Image bauen
docker build -t turnierplaner:latest .
# Container starten
docker run -d -p 8080:80 --name turnierplaner turnierplaner:latest
# Container stoppen
docker stop turnierplaner
docker rm turnierplaner
```
### Anwendung aufrufen
Nach dem Start ist die Anwendung erreichbar unter:
- **http://localhost:8080**
### Nützliche Befehle
```powershell
# Container Status prüfen
docker ps
# Container Logs anzeigen
docker logs turnierplaner
# In Container einsteigen (für Debugging)
docker exec -it turnierplaner sh
# Image neu bauen (nach Änderungen)
docker-compose build
docker-compose up -d
# Alle Container/Images aufräumen
docker-compose down
docker system prune -a
```
### Port ändern
Falls Port 8080 bereits belegt ist, ändere in [docker-compose.yml](docker-compose.yml):
```yaml
ports:
- "3000:80" # Statt 8080
```
### Daten-Persistenz
Die Anwendung speichert alle Daten im Browser LocalStorage. Die Daten bleiben auch nach Container-Neustart erhalten, solange du denselben Browser verwendest.
### Troubleshooting
**Port bereits belegt:**
```powershell
# Prüfe welcher Prozess Port 8080 nutzt
netstat -ano | findstr :8080
# Stoppe den Prozess oder ändere den Port in docker-compose.yml
```
**Container startet nicht:**
```powershell
# Prüfe Logs
docker logs turnierplaner
# Prüfe ob Docker Desktop läuft
docker ps
```
**Nach Code-Änderungen:**
```powershell
# Container stoppen und neu bauen
docker-compose down
docker-compose build --no-cache
docker-compose up -d
```

18
Dockerfile Normal file
View File

@ -0,0 +1,18 @@
# Multi-stage build für minimales Image
FROM nginx:alpine
# Kopiere alle statischen Dateien
COPY index.html /usr/share/nginx/html/
COPY planning.html /usr/share/nginx/html/
COPY script.js /usr/share/nginx/html/
COPY planning.js /usr/share/nginx/html/
COPY styles.css /usr/share/nginx/html/
# Kopiere nginx Konfiguration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Exponiere Port 80
EXPOSE 80
# nginx läuft automatisch im Vordergrund
CMD ["nginx", "-g", "daemon off;"]

17
docker-compose.yml Normal file
View File

@ -0,0 +1,17 @@
version: '3.8'
services:
turnierplaner:
build:
context: .
dockerfile: Dockerfile
container_name: turnierplaner
ports:
- "18080:80"
restart: unless-stopped
networks:
- turnierplaner-network
networks:
turnierplaner-network:
driver: bridge

64
index.html Normal file
View File

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no">
<title>Turnierplaner - Team-Eingabe</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<h1>🏐 NVJ Turnierplaner</h1>
<div class="settings">
<div class="settings-group">
<label for="fieldCount">Anzahl Felder:</label>
<input type="number" id="fieldCount" min="1" value="1" placeholder="z.B. 4">
</div>
</div>
<div class="leagues-container">
<!-- Bundesliga -->
<div class="league">
<div class="league-header">
<div class="league-title">
<h2>Bundesliga</h2>
<div class="team-count" id="bundesliga-count">0 Teams</div>
</div>
<button class="add-team-btn" onclick="addTeam('bundesliga')">+</button>
</div>
<div class="teams-list" id="bundesliga-list">
<div class="empty-message">Keine Teams hinzugefügt. Klicke auf + um zu beginnen!</div>
</div>
</div>
<!-- Champions League -->
<div class="league">
<div class="league-header">
<div class="league-title">
<h2>Champions League</h2>
<div class="team-count" id="champions-count">0 Teams</div>
</div>
<button class="add-team-btn" onclick="addTeam('champions')">+</button>
</div>
<div class="teams-list" id="champions-list">
<div class="empty-message">Keine Teams hinzugefügt. Klicke auf + um zu beginnen!</div>
</div>
</div>
</div>
<div class="export-section">
<div class="export-buttons">
<button class="export-btn" onclick="exportData()">📥 Daten exportieren (JSON)</button>
<button class="export-btn" onclick="importData()">📤 Daten importieren</button>
<input type="file" id="import-file" accept=".json" style="display: none;" onchange="handleFileImport(event)">
</div>
<button class="next-btn" onclick="navigateToPlanning()">Weiter →</button>
</div>
<div id="export-output"></div>
</div>
<script src="script.js"></script>
</body>
</html>

28
nginx.conf Normal file
View File

@ -0,0 +1,28 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip Kompression für bessere Performance
gzip on;
gzip_types text/css application/javascript application/json;
gzip_min_length 1000;
location / {
try_files $uri $uri/ /index.html;
}
# Cache-Control Header für statische Assets
location ~* \.(js|css)$ {
expires 1d;
add_header Cache-Control "public, immutable";
}
# Keine Cache für HTML-Dateien
location ~* \.(html)$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
}

721
planning.html Normal file
View File

@ -0,0 +1,721 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no">
<title>Turnierplaner - Planung</title>
<link rel="stylesheet" href="styles.css">
<style>
.planning-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
background: white;
border-radius: 8px;
margin-bottom: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
gap: 10px;
}
.back-btn {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
border: none;
padding: 10px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
min-height: 44px;
touch-action: manipulation;
transition: transform 0.3s;
white-space: nowrap;
}
.back-btn:hover {
transform: translateY(-2px);
}
.menu-bar {
display: flex;
gap: 10px;
background: white;
padding: 12px 15px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
flex-wrap: wrap;
}
.menu-btn {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
border: none;
padding: 12px 20px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 0.95em;
min-height: 44px;
touch-action: manipulation;
transition: transform 0.3s;
white-space: nowrap;
}
.menu-btn:hover {
transform: translateY(-2px);
}
.timer-section {
display: flex;
align-items: center;
gap: 10px;
background: #f8f9fa;
padding: 10px 15px;
border-radius: 4px;
}
.timer-display {
font-size: 1.5em;
font-weight: bold;
color: #2ecc71;
font-family: 'Courier New', monospace;
min-width: 80px;
}
.timer-control-btn {
background: #2ecc71;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
min-height: 40px;
touch-action: manipulation;
font-size: 0.85em;
}
.timer-control-btn:hover {
background: #27ae60;
}
.timer-input {
width: 100px;
padding: 8px 10px;
border: 2px solid #2ecc71;
border-radius: 4px;
font-size: 0.95em;
text-align: center;
min-height: 40px;
}
.timer-input:focus {
outline: none;
border-color: #27ae60;
box-shadow: 0 0 0 3px rgba(46, 204, 113, 0.1);
}
.fields-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 15px;
flex: 1;
overflow: hidden;
}
@media (max-width: 768px) {
.fields-container {
grid-template-columns: 1fr;
}
}
.fields-section {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
height: 100%;
}
.fields-header {
padding: 15px;
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
flex-shrink: 0;
}
.fields-header h3 {
margin: 0;
font-size: 1.2em;
}
.fields-grid {
padding: 15px;
overflow-y: auto;
flex: 1;
display: grid;
grid-template-columns: 120px 1fr 1fr;
gap: 12px;
-webkit-overflow-scrolling: touch;
}
.field-info-box {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
padding: 12px;
border-radius: 6px;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
font-weight: 600;
min-height: 60px;
border: 2px solid #1e8449;
font-size: 1.2em;
}
.field-info-number {
font-size: 1.2em;
}
.field-info-team {
display: none;
-webkit-box-orient: vertical;
}
.waiting-teams-section {
background: #f8f9fa;
padding: 15px;
border-top: 2px solid #e0e0e0;
flex-shrink: 0;
}
.waiting-teams-label {
font-weight: 600;
color: #333;
margin-bottom: 10px;
font-size: 0.95em;
}
.waiting-teams-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.waiting-team-badge {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
padding: 8px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 6px;
}
.waiting-team-badge::before {
content: "⏳";
}
.no-waiting-teams {
color: #999;
font-style: italic;
font-size: 0.9em;
}
.field-card {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
border: 2px solid #e0e0e0;
cursor: pointer;
transition: all 0.3s;
text-align: center;
min-height: 80px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.field-card:hover {
border-color: #2ecc71;
background: #f0fdf4;
transform: translateY(-2px);
}
.field-card-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.team-name {
font-size: 0.9em;
color: #333;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.score-input {
width: 50px;
padding: 6px;
font-size: 0.95em;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: white;
margin: 5% auto;
padding: 20px;
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: slideIn 0.3s;
}
@keyframes slideIn {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #2ecc71;
}
.modal-header h2 {
margin: 0;
font-size: 1.5em;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 1.8em;
cursor: pointer;
color: #999;
transition: color 0.3s;
}
.close-btn:hover {
color: #333;
}
.modal-form {
display: flex;
flex-direction: column;
gap: 15px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 600;
color: #333;
}
.form-group input,
.form-group select {
padding: 12px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 1em;
min-height: 44px;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #2ecc71;
box-shadow: 0 0 0 3px rgba(46, 204, 113, 0.1);
}
.modal-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 15px;
border-top: 2px solid #f0f0f0;
}
.modal-footer button {
padding: 12px 24px;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
min-height: 44px;
touch-action: manipulation;
transition: transform 0.3s;
}
.cancel-btn {
background: #f0f0f0;
color: #333;
}
.cancel-btn:hover {
transform: translateY(-2px);
}
.submit-btn {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
}
.submit-btn:hover {
transform: translateY(-2px);
}
.points-display-content {
display: flex;
flex-direction: column;
gap: 30px;
max-height: 70vh;
overflow-y: auto;
}
.league-points-section {
border: 2px solid #2ecc71;
border-radius: 8px;
overflow: hidden;
}
.league-points-header {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
padding: 12px 15px;
font-weight: 600;
font-size: 1.05em;
}
.points-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
.points-table th,
.points-table td {
padding: 10px;
border: 1px solid #e0e0e0;
text-align: center;
}
.points-table th {
background: #f0fdf4;
font-weight: 600;
color: #2ecc71;
}
.points-table td:first-child,
.points-table th:first-child {
text-align: left;
padding-left: 12px;
min-width: 120px;
}
.points-table tr:nth-child(even) {
background: #fafbfc;
}
.points-table tr:hover {
background: #f0fdf4;
}
.points-value {
font-weight: 600;
padding: 6px 8px;
border-radius: 4px;
min-width: 40px;
}
.points-value.positive {
background: #d4edda;
color: #155724;
}
.points-value.negative {
background: #f8d7da;
color: #721c24;
}
.points-value.neutral {
background: #e2e3e5;
color: #383d41;
}
.points-total {
font-weight: 700;
background: #2ecc71;
color: white;
border-radius: 4px;
}
.scoreboard-content {
display: flex;
flex-direction: column;
gap: 20px;
max-height: 70vh;
overflow-y: auto;
}
.scoreboard-league {
border: 2px solid #3498db;
border-radius: 8px;
overflow: hidden;
}
.scoreboard-league-header {
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
color: white;
padding: 12px 15px;
font-weight: 600;
font-size: 1.05em;
}
.scoreboard-list {
list-style: none;
padding: 0;
margin: 0;
}
.scoreboard-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid #e0e0e0;
transition: background 0.2s;
}
.scoreboard-item:last-child {
border-bottom: none;
}
.scoreboard-item:hover {
background: #f0f8ff;
}
.scoreboard-rank {
font-size: 1.3em;
font-weight: 700;
color: #3498db;
min-width: 35px;
text-align: center;
}
.scoreboard-team-name {
flex: 1;
margin: 0 15px;
font-weight: 500;
color: #333;
}
.scoreboard-points {
background: #3498db;
color: white;
padding: 6px 12px;
border-radius: 20px;
font-weight: 700;
min-width: 70px;
text-align: center;
}
.scoreboard-points.positive {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
}
.scoreboard-points.negative {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
}
</style>
</head>
<body>
<div class="container">
<div class="planning-header">
<button class="back-btn" onclick="goBack()">← Zurück</button>
<h1 style="flex: 1; text-align: center; margin: 0; font-size: 1.3em; color: #333;">🏐 Turnierplanung</h1>
</div>
<div class="menu-bar">
<button class="menu-btn" onclick="openPointsModal()">📊 Punkte anzeigen</button>
<button class="menu-btn" onclick="openScoreboard()" style="background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);">🏆 Scoreboard</button>
<button class="menu-btn" onclick="nextRound()" style="background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%); margin-left: auto; margin-right: auto;">⚡ Nächste Runde</button>
<button class="menu-btn" onclick="confirmReset()" style="background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); margin-left: auto;">🔄 Zurücksetzen</button>
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px;">
<span style="font-weight: 600; color: #333;">Spielzeit:</span>
<div class="timer-section">
<div class="timer-display" id="timerDisplay">00:00</div>
<button class="timer-control-btn" id="timerBtn" onclick="toggleTimer()">Start</button>
<input type="text" class="timer-input" id="timerInput" placeholder="Sek. oder MM:SS" title="Sekunden oder MM:SS eingeben und Enter drücken">
</div>
</div>
</div>
<div class="fields-container">
<!-- Bundesliga Felder -->
<div class="fields-section">
<div class="fields-header">
<h3>Bundesliga</h3>
</div>
<div class="fields-grid" id="bundesliga-fields">
</div>
<div class="waiting-teams-section">
<div class="waiting-teams-label">Wartende Teams:</div>
<div class="waiting-teams-list" id="bundesliga-waiting">
</div>
</div>
</div>
<!-- Champions League Felder -->
<div class="fields-section">
<div class="fields-header">
<h3>Champions League</h3>
</div>
<div class="fields-grid" id="champions-fields">
</div>
<div class="waiting-teams-section">
<div class="waiting-teams-label">Wartende Teams:</div>
<div class="waiting-teams-list" id="champions-waiting">
</div>
</div>
</div>
</div>
</div>
<!-- Punkte Modal -->
<div id="pointsModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Punkte anzeigen</h2>
<button class="close-btn" onclick="closePointsModal()">×</button>
</div>
<div class="points-display-content" id="pointsDisplayContent">
<!-- Wird dynamisch gefüllt -->
</div>
</div>
</div>
<!-- Scoreboard Modal -->
<div id="scoreboardModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>🏆 Scoreboard</h2>
<button class="close-btn" onclick="closeScoreboard()">×</button>
</div>
<div class="scoreboard-content" id="scoreboardContent">
<!-- Wird dynamisch gefüllt -->
</div>
</div>
</div>
<!-- Reset Confirmation Modal -->
<div id="resetConfirmModal" class="modal">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2>⚠️ Turnier zurücksetzen</h2>
<button class="close-btn" onclick="closeResetConfirm()">×</button>
</div>
<div style="padding: 20px; text-align: center;">
<p style="color: #333; margin-bottom: 20px; font-size: 1.05em;">
Alle Runden, Scores und Punkte werden gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden!
</p>
<div style="display: flex; gap: 10px; justify-content: center;">
<button class="cancel-btn" onclick="closeResetConfirm()">Abbrechen</button>
<button class="submit-btn" style="background: #e74c3c;" onclick="performReset()">Ja, Zurücksetzen</button>
</div>
</div>
</div>
</div>
<!-- Ergebnisse Modal (nicht mehr verwendet) -->
<div id="resultsModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h2>Ergebnisse eintragen</h2>
<button class="close-btn" onclick="closeResultsModal()">×</button>
</div>
<form class="modal-form" onsubmit="saveResults(event)">
<div class="form-group">
<label for="resultsField">Feld:</label>
<select id="resultsField" required>
<option value="">-- Feld wählen --</option>
</select>
</div>
<div class="form-group">
<label for="resultsTeam">Team:</label>
<input type="text" id="resultsTeam" placeholder="Teamname" readonly>
</div>
<div class="form-group">
<label for="resultsSets">Sätze gewonnen:</label>
<input type="number" id="resultsSets" min="0" placeholder="z.B. 2" required>
</div>
<div class="form-group">
<label for="resultsOpponent">Gegnerischer Team:</label>
<input type="text" id="resultsOpponent" placeholder="Name des Gegners">
</div>
<div class="modal-footer">
<button type="button" class="cancel-btn" onclick="closeResultsModal()">Abbrechen</button>
<button type="submit" class="submit-btn">Speichern</button>
</div>
</form>
</div>
</div>
<script src="planning.js"></script>
</body>
</html>

799
planning.js Normal file
View File

@ -0,0 +1,799 @@
const STORAGE_KEY = 'turnierplaner_data';
const ROTATION_STATE_KEY = 'turnierplaner_rotation';
const SCORES_KEY = 'turnierplaner_scores';
let timerInterval = null;
let timerSeconds = 0;
let isRunning = false;
let allTeams = [];
let fieldCount = 0; // Anzahl der physischen Felder (jedes Feld hat 2 Feldhälften)
let rotationState = {};
let matchScores = {}; // { "league:fieldNum": {team1Score, team2Score}, ... }
document.addEventListener('DOMContentLoaded', () => {
loadTournamentData();
setupTimerInput();
loadMatchScores();
});
function loadTournamentData() {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) {
console.warn('Keine Turnierdaten gefunden');
return;
}
const data = JSON.parse(stored);
allTeams = data;
fieldCount = parseInt(data.fieldCount) || 0;
// Lade oder initialisiere Rotations-State
loadRotationState(data);
// Populate fields mit aktuellem State
displayCurrentRound();
}
function loadRotationState(data) {
const stored = localStorage.getItem(ROTATION_STATE_KEY);
if (stored) {
rotationState = JSON.parse(stored);
} else {
// Initialisiere mit Startzustand
rotationState = {
bundesliga: {
round: 0,
teamOrder: data.bundesliga.map((t, i) => i)
},
champions: {
round: 0,
teamOrder: data.champions.map((t, i) => i)
}
};
saveRotationState();
}
}
function saveRotationState() {
localStorage.setItem(ROTATION_STATE_KEY, JSON.stringify(rotationState));
}
function displayCurrentRound() {
displayLeagueRound('bundesliga', allTeams.bundesliga);
displayLeagueRound('champions', allTeams.champions);
}
function displayLeagueRound(league, teams) {
// Jedes Feld hat 2 Feldhälften, also maximal fieldCount * 2 Teams können gleichzeitig spielen
const maxPlayingTeams = fieldCount * 2;
const playingTeamsCount = Math.min(teams.length, maxPlayingTeams);
const fieldsPerLeague = Math.ceil(playingTeamsCount / 2); // Anzahl Felder für diese Liga
const state = rotationState[league];
// Bestimme Offset für Feldnummern - Champions League beginnt nach Bundesliga
let fieldNumberOffset = 0;
if (league === 'champions') {
const bundesligaPlayingTeams = Math.min(allTeams.bundesliga.length, fieldCount * 2);
fieldNumberOffset = Math.ceil(bundesligaPlayingTeams / 2);
}
// Aktualisiere Felder basierend auf teamOrder
const container = document.getElementById(`${league}-fields`);
container.innerHTML = '';
for (let i = 0; i < fieldsPerLeague; i++) {
// Info-Box für Feldnummer (nur Label)
const fieldInfoBox = document.createElement('div');
fieldInfoBox.className = 'field-info-box';
const fieldNum = fieldNumberOffset + i + 1;
fieldInfoBox.innerHTML = `<div class="field-info-number">Feld ${fieldNum}:</div>`;
container.appendChild(fieldInfoBox);
// Team 1 (mittlere Spalte) mit Score-Input
const team1Index = state.teamOrder[i * 2];
const team1 = teams[team1Index] || {};
const fieldCard1 = document.createElement('div');
fieldCard1.className = 'field-card';
const scoreKey = getScoreKey(league, fieldNum);
const existingScore = matchScores[scoreKey] || { team1Score: '', team2Score: '' };
fieldCard1.innerHTML = `
<div class="field-card-content">
<div class="team-name">${team1.name || '-'}</div>
<input type="number" class="score-input" value="${existingScore.team1Score}" placeholder="0"
onchange="updateMatchScore('${league}', ${fieldNum}, this.value, document.getElementById('score-${league}-${fieldNum}-t2').value)">
</div>
`;
fieldCard1.id = `score-${league}-${fieldNum}-t1-card`;
container.appendChild(fieldCard1);
// Team 2 (rechte Spalte) mit Score-Input
const team2Index = state.teamOrder[i * 2 + 1];
const team2 = teams[team2Index] || {};
const fieldCard2 = document.createElement('div');
fieldCard2.className = 'field-card';
fieldCard2.innerHTML = `
<div class="field-card-content">
<input type="number" class="score-input" value="${existingScore.team2Score}" placeholder="0"
id="score-${league}-${fieldNum}-t2"
onchange="updateMatchScore('${league}', ${fieldNum}, document.getElementById('score-${league}-${fieldNum}-t1-card').querySelector('.score-input').value, this.value)">
<div class="team-name">${team2.name || '-'}</div>
</div>
`;
container.appendChild(fieldCard2);
}
// Aktualisiere wartende Teams
updateWaitingTeams(league, teams, fieldsPerLeague, state.teamOrder);
}
function updateWaitingTeams(league, teams, fieldsPerLeague, teamOrder) {
const container = document.getElementById(`${league}-waiting`);
container.innerHTML = '';
// Anzahl der spielenden Teams = fieldsPerLeague * 2 (2 Teams pro Feld)
const playingTeamsCount = fieldsPerLeague * 2;
const waitingIndices = teamOrder.slice(playingTeamsCount);
if (waitingIndices.length === 0) {
container.innerHTML = '<div class="no-waiting-teams">Alle Teams spielen</div>';
} else {
waitingIndices.forEach(teamIndex => {
const team = teams[teamIndex];
const badge = document.createElement('div');
badge.className = 'waiting-team-badge';
badge.textContent = team.name;
badge.title = `${team.club || ''} - ${team.motto || ''}`;
container.appendChild(badge);
});
}
}
function populateFields(league, teams, fieldCount) {
const container = document.getElementById(`${league}-fields`);
const totalFields = parseInt(fieldCount);
const fieldsPerLeague = Math.ceil(totalFields / 2);
container.innerHTML = '';
for (let i = 1; i <= fieldsPerLeague; i++) {
const fieldCard = document.createElement('div');
fieldCard.className = 'field-card';
// Zuweisung von Teams zu Feldern (Round-Robin)
const teamIndex = (i - 1) % teams.length;
const team = teams[teamIndex] || {};
fieldCard.innerHTML = `
<div class="field-number">Feld ${i}</div>
<div class="field-team">${team.name || '-'}</div>
`;
fieldCard.setAttribute('data-field', i);
fieldCard.setAttribute('data-league', league);
fieldCard.setAttribute('data-team', team.name || '');
container.appendChild(fieldCard);
}
}
function populateWaitingTeams(league, teams, fieldCount) {
const container = document.getElementById(`${league}-waiting`);
const totalFields = parseInt(fieldCount);
const fieldsPerLeague = Math.ceil(totalFields / 2);
container.innerHTML = '';
// Teams die auf Feldern spielen
const playingTeamIndices = new Set();
for (let i = 0; i < fieldsPerLeague; i++) {
playingTeamIndices.add(i % teams.length);
}
// Wartende Teams sind alle Teams, die nicht gerade spielen
const waitingTeams = teams.filter((team, index) => !playingTeamIndices.has(index));
if (waitingTeams.length === 0) {
container.innerHTML = '<div class="no-waiting-teams">Alle Teams spielen</div>';
} else {
waitingTeams.forEach(team => {
const badge = document.createElement('div');
badge.className = 'waiting-team-badge';
badge.textContent = team.name;
badge.title = `${team.club || ''} - ${team.motto || ''}`;
container.appendChild(badge);
});
}
}
// Timer Functions
function setupTimerInput() {
const timerInput = document.getElementById('timerInput');
timerInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
const value = timerInput.value.trim();
if (value) {
parseAndSetTimer(value);
timerInput.value = '';
timerInput.blur(); // Remove focus after input
}
}
});
}
function parseAndSetTimer(timeStr) {
let seconds = 0;
if (timeStr.includes(':')) {
// Parse MM:SS format
const parts = timeStr.split(':');
if (parts.length === 2) {
const minutes = parseInt(parts[0]) || 0;
seconds = parseInt(parts[1]) || 0;
seconds = minutes * 60 + seconds;
}
} else {
// Parse as just seconds
seconds = parseInt(timeStr) || 0;
}
if (seconds > 0) {
// Stop current timer if running
if (isRunning) {
pauseTimer();
}
timerSeconds = seconds;
updateTimerDisplay();
// Start timer automatically
startTimer();
}
}
function toggleTimer() {
if (isRunning) {
pauseTimer();
} else {
startTimer();
}
}
function startTimer() {
if (isRunning) return;
isRunning = true;
const btn = document.getElementById('timerBtn');
btn.textContent = 'Pause';
timerInterval = setInterval(() => {
if (timerSeconds > 0) {
timerSeconds--;
updateTimerDisplay();
} else {
pauseTimer();
}
}, 1000);
}
function pauseTimer() {
if (!isRunning) return;
isRunning = false;
const btn = document.getElementById('timerBtn');
btn.textContent = 'Start';
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
function updateTimerDisplay() {
const minutes = Math.floor(timerSeconds / 60);
const seconds = timerSeconds % 60;
const display = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
document.getElementById('timerDisplay').textContent = display;
}
// Modal Functions
function openPointsModal() {
document.getElementById('pointsModal').style.display = 'block';
}
function closePointsModal() {
document.getElementById('pointsModal').style.display = 'none';
}
function openResultsModal() {
document.getElementById('resultsModal').style.display = 'block';
}
function closeResultsModal() {
document.getElementById('resultsModal').style.display = 'none';
}
function savePoints(event) {
event.preventDefault();
const field = document.getElementById('pointsField').value;
const team = document.getElementById('pointsTeam').value;
const points = document.getElementById('pointsValue').value;
if (!field || !points) {
alert('Bitte alle Felder ausfüllen');
return;
}
// TODO: Punkte speichern (z.B. in localStorage oder an Server)
console.log('Punkte gespeichert:', { field, team, points });
alert(`✅ Punkte für ${team} gespeichert!`);
// Reset form
document.getElementById('pointsField').value = '';
document.getElementById('pointsTeam').value = '';
document.getElementById('pointsValue').value = '';
closePointsModal();
}
function saveResults(event) {
event.preventDefault();
const field = document.getElementById('resultsField').value;
const team = document.getElementById('resultsTeam').value;
const sets = document.getElementById('resultsSets').value;
const opponent = document.getElementById('resultsOpponent').value;
if (!field || !sets) {
alert('Bitte alle Pflichtfelder ausfüllen');
return;
}
// TODO: Ergebnisse speichern (z.B. in localStorage oder an Server)
console.log('Ergebnis gespeichert:', { field, team, sets, opponent });
alert(`✅ Ergebnis für ${team} gespeichert!`);
// Reset form
document.getElementById('resultsField').value = '';
document.getElementById('resultsTeam').value = '';
document.getElementById('resultsSets').value = '';
document.getElementById('resultsOpponent').value = '';
closeResultsModal();
}
// Close modal when clicking outside
window.addEventListener('click', (event) => {
const pointsModal = document.getElementById('pointsModal');
const resultsModal = document.getElementById('resultsModal');
if (event.target === pointsModal) {
closePointsModal();
}
if (event.target === resultsModal) {
closeResultsModal();
}
});
function goBack() {
// Stop timer before navigating
if (isRunning) {
pauseTimer();
}
window.location.href = 'index.html';
}
// Rotation Logic
function nextRound() {
// Inkrementiere Runden-Nummer
rotationState.bundesliga.round++;
rotationState.champions.round++;
// Leere die Scores für die neue Runde
matchScores = {};
saveMatchScores();
rotateLeague('bundesliga', allTeams.bundesliga);
rotateLeague('champions', allTeams.champions);
saveRotationState();
displayCurrentRound();
console.log('Nächste Runde!', rotationState);
}
function rotateLeague(league, teams) {
// Jedes Feld hat 2 Feldhälften - maximale spielende Teams = fieldCount * 2
const maxPlayingTeams = fieldCount * 2;
const playingTeamsCount = Math.min(teams.length, maxPlayingTeams);
const state = rotationState[league];
const totalTeams = teams.length;
const waitingCount = totalTeams - playingTeamsCount;
// Fall 1: Keine wartenden Teams (Anzahl Teams = fieldCount * 2)
// Team 1 bleibt stehen, alle anderen rotieren um 1 Position weiter
if (waitingCount === 0) {
// [0, 1, 2, 3, 4, 5] -> [0, 2, 3, 4, 5, 1]
// Team an Index 0 bleibt, Team an Index 1 geht ans Ende, Rest rückt auf
if (state.teamOrder.length > 1) {
const secondTeam = state.teamOrder.splice(1, 1)[0];
state.teamOrder.push(secondTeam);
}
console.log(`${league} Fall 1: Team ${teams[state.teamOrder[0]].name} bleibt stehen, andere rotieren`);
}
// Fall 2: Genau 1 wartendes Team (Anzahl Teams = fieldCount * 2 + 1)
// Alle rotieren um 1 Position, wartendes Team kommt aufs Randfeld
else if (waitingCount === 1) {
// [0, 1, 2, 3, 4, 5, 6(wartend)] -> [1, 2, 3, 4, 5, 6, 0]
// Einfach erstes Team ans Ende verschieben
const first = state.teamOrder.shift();
state.teamOrder.push(first);
console.log(`${league} Fall 2: Team ${teams[first].name} geht warten, Team ${teams[state.teamOrder[playingTeamsCount - 1]].name} kommt ins Spiel`);
}
// Fall 3: Mehrere wartende Teams (Anzahl Teams > fieldCount * 2 + 1)
// Alle wartenden sollen spielen, max 1 Runde Pause
else {
// [0, 1, 2, 3(spielend), 4, 5, 6, 7(wartend)] -> [4, 5, 6, 7(jetzt spielend), 0, 1, 2, 3(jetzt wartend)]
// Wartende Teams kommen nach vorne, spielende Teams gehen warten
const playingTeams = state.teamOrder.slice(0, playingTeamsCount);
const waitingTeams = state.teamOrder.slice(playingTeamsCount);
state.teamOrder = [...waitingTeams, ...playingTeams];
console.log(`${league} Fall 3: Wartende Teams spielen jetzt, spielende Teams warten`);
}
}
// ===== SCORE & PUNKTE-SYSTEM =====
function loadMatchScores() {
const stored = localStorage.getItem(SCORES_KEY);
if (stored) {
matchScores = JSON.parse(stored);
} else {
matchScores = {};
}
}
function saveMatchScores() {
localStorage.setItem(SCORES_KEY, JSON.stringify(matchScores));
}
function getScoreKey(league, fieldNum) {
return `${league}:${fieldNum}`;
}
function updateMatchScore(league, fieldNum, team1Score, team2Score) {
const key = getScoreKey(league, fieldNum);
matchScores[key] = {
round: rotationState[league].round,
team1Score: parseInt(team1Score) || 0,
team2Score: parseInt(team2Score) || 0
};
saveMatchScores();
}
function calculatePoints(team1Score, team2Score) {
// Berechnet Punkte für Team 1 und Team 2 basierend auf Spielstand
const diff = Math.abs(team1Score - team2Score);
if (team1Score > team2Score) {
// Team 1 gewinnt
return {
team1Points: diff + 2,
team2Points: -diff
};
} else if (team2Score > team1Score) {
// Team 2 gewinnt
return {
team1Points: -diff,
team2Points: diff + 2
};
} else {
// Unentschieden
return {
team1Points: 0,
team2Points: 0
};
}
}
function getTeamPointsHistory() {
// Erstellt eine Punkte-Historie für alle Teams in beiden Ligen
const history = {
bundesliga: {},
champions: {}
};
// Initialisiere leere Arrays für jedes Team
allTeams.bundesliga.forEach((team, idx) => {
history.bundesliga[idx] = [];
});
allTeams.champions.forEach((team, idx) => {
history.champions[idx] = [];
});
// Gehe durch alle gespeicherten Scores
Object.keys(matchScores).forEach(key => {
const [league, fieldNum] = key.split(':');
const score = matchScores[key];
const fieldNumInt = parseInt(fieldNum);
const state = rotationState[league];
const teamsInLeague = league === 'bundesliga' ? allTeams.bundesliga : allTeams.champions;
// Berechne fieldsPerLeague basierend auf fieldCount
const maxPlayingTeams = fieldCount * 2;
const playingTeamsCount = Math.min(teamsInLeague.length, maxPlayingTeams);
const fieldsPerLeague = Math.ceil(playingTeamsCount / 2);
// Bestimme Offset für Feldnummern
const bundesligaPlayingTeams = Math.min(allTeams.bundesliga.length, fieldCount * 2);
const bundesligaFields = Math.ceil(bundesligaPlayingTeams / 2);
const fieldOffset = league === 'bundesliga' ? 0 : bundesligaFields;
const i = fieldNumInt - fieldOffset - 1;
if (i >= 0 && i < fieldsPerLeague && state && state.teamOrder) {
const team1Index = state.teamOrder[i * 2];
const team2Index = state.teamOrder[i * 2 + 1];
if (team1Index !== undefined && team2Index !== undefined) {
const points = calculatePoints(score.team1Score, score.team2Score);
if (!history[league][team1Index][score.round]) {
history[league][team1Index][score.round] = 0;
}
if (!history[league][team2Index][score.round]) {
history[league][team2Index][score.round] = 0;
}
history[league][team1Index][score.round] = points.team1Points;
history[league][team2Index][score.round] = points.team2Points;
}
}
});
return history;
}
// ===== MODAL FUNCTIONS =====
function openPointsModal() {
const history = getTeamPointsHistory();
const content = document.getElementById('pointsDisplayContent');
content.innerHTML = '';
// Bundesliga
if (allTeams.bundesliga.length > 0) {
content.appendChild(createLeaguePointsSection('bundesliga', 'Bundesliga', allTeams.bundesliga, history.bundesliga));
}
// Champions League
if (allTeams.champions.length > 0) {
content.appendChild(createLeaguePointsSection('champions', 'Champions League', allTeams.champions, history.champions));
}
document.getElementById('pointsModal').style.display = 'block';
}
function createLeaguePointsSection(leagueId, leagueName, teams, history) {
const section = document.createElement('div');
section.className = 'league-points-section';
// Header
const header = document.createElement('div');
header.className = 'league-points-header';
header.innerHTML = leagueName;
section.appendChild(header);
// Table
const table = document.createElement('table');
table.className = 'points-table';
// Berechne maximale Runde
let maxRound = 0;
Object.keys(history).forEach(teamId => {
if (history[teamId] && history[teamId].length) {
maxRound = Math.max(maxRound, history[teamId].length - 1);
}
});
// Header Row
const headerRow = document.createElement('tr');
headerRow.innerHTML = '<th>Team</th>';
for (let r = 0; r <= maxRound; r++) {
const th = document.createElement('th');
th.innerHTML = `Runde ${r + 1}`;
headerRow.appendChild(th);
}
const totalTh = document.createElement('th');
totalTh.innerHTML = 'Gesamt';
headerRow.appendChild(totalTh);
table.appendChild(headerRow);
// Team Rows
teams.forEach((team, teamIdx) => {
const row = document.createElement('tr');
// Team Name
const nameCell = document.createElement('td');
nameCell.style.textAlign = 'left';
nameCell.style.fontWeight = '500';
nameCell.innerHTML = team.name;
row.appendChild(nameCell);
// Points per round
let totalPoints = 0;
const teamHistory = history[teamIdx] || [];
for (let r = 0; r <= maxRound; r++) {
const cell = document.createElement('td');
const points = teamHistory[r] !== undefined ? teamHistory[r] : '-';
if (points !== '-') {
totalPoints += points;
const pointsDiv = document.createElement('div');
pointsDiv.className = 'points-value';
if (points > 0) {
pointsDiv.classList.add('positive');
pointsDiv.innerHTML = `+${points}`;
} else if (points < 0) {
pointsDiv.classList.add('negative');
pointsDiv.innerHTML = `${points}`;
} else {
pointsDiv.classList.add('neutral');
pointsDiv.innerHTML = '0';
}
cell.appendChild(pointsDiv);
} else {
cell.innerHTML = '-';
}
row.appendChild(cell);
}
// Total Points
const totalCell = document.createElement('td');
const totalDiv = document.createElement('div');
totalDiv.className = 'points-value points-total';
totalDiv.innerHTML = totalPoints > 0 ? `+${totalPoints}` : `${totalPoints}`;
totalCell.appendChild(totalDiv);
row.appendChild(totalCell);
table.appendChild(row);
});
section.appendChild(table);
return section;
}
function closePointsModal() {
document.getElementById('pointsModal').style.display = 'none';
}
function openScoreboard() {
const history = getTeamPointsHistory();
const content = document.getElementById('scoreboardContent');
content.innerHTML = '';
// Erstelle Daten für beide Ligen mit Gesamtpunkten
const leaguesData = [
{ id: 'bundesliga', name: 'Bundesliga', teams: allTeams.bundesliga, history: history.bundesliga },
{ id: 'champions', name: 'Champions League', teams: allTeams.champions, history: history.champions }
];
leaguesData.forEach(league => {
if (league.teams.length > 0) {
// Berechne Gesamtpunkte für jedes Team
const teamScores = league.teams.map((team, idx) => {
const teamHistory = league.history[idx] || [];
const totalPoints = teamHistory.reduce((sum, pts) => sum + (pts || 0), 0);
return { idx, name: team.name, points: totalPoints };
});
// Sortiere absteigend nach Punkten
teamScores.sort((a, b) => b.points - a.points);
// Erstelle die Liga-Section
const section = document.createElement('div');
section.className = 'scoreboard-league';
const header = document.createElement('div');
header.className = 'scoreboard-league-header';
header.innerHTML = league.name;
section.appendChild(header);
const list = document.createElement('ul');
list.className = 'scoreboard-list';
teamScores.forEach((score, rank) => {
const item = document.createElement('li');
item.className = 'scoreboard-item';
const rankDiv = document.createElement('div');
rankDiv.className = 'scoreboard-rank';
rankDiv.innerHTML = `${rank + 1}.`;
item.appendChild(rankDiv);
const nameDiv = document.createElement('div');
nameDiv.className = 'scoreboard-team-name';
nameDiv.innerHTML = score.name;
item.appendChild(nameDiv);
const pointsDiv = document.createElement('div');
pointsDiv.className = 'scoreboard-points';
if (score.points > 0) {
pointsDiv.classList.add('positive');
pointsDiv.innerHTML = `+${score.points}`;
} else if (score.points < 0) {
pointsDiv.classList.add('negative');
pointsDiv.innerHTML = `${score.points}`;
} else {
pointsDiv.innerHTML = '0';
}
item.appendChild(pointsDiv);
list.appendChild(item);
});
section.appendChild(list);
content.appendChild(section);
}
});
document.getElementById('scoreboardModal').style.display = 'block';
}
function closeScoreboard() {
document.getElementById('scoreboardModal').style.display = 'none';
}
function confirmReset() {
document.getElementById('resetConfirmModal').style.display = 'block';
}
function closeResetConfirm() {
document.getElementById('resetConfirmModal').style.display = 'none';
}
function performReset() {
// Lade die Rohdaten (Teams, Feldanzahl)
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return;
const data = JSON.parse(stored);
// Setze Rotationsstates zurück auf Anfangszustand
rotationState = {
bundesliga: {
round: 0,
teamOrder: data.bundesliga.map((t, i) => i)
},
champions: {
round: 0,
teamOrder: data.champions.map((t, i) => i)
}
};
// Leere alle Scores
matchScores = {};
// Speichere zurückgesetzte States
saveRotationState();
saveMatchScores();
// Aktualisiere die UI
displayCurrentRound();
// Schließe das Modal
closeResetConfirm();
alert('✅ Turnier wurde zurückgesetzt! Alle Runden und Scores wurden gelöscht.');
}
function openResultsModal() {
document.getElementById('resultsModal').style.display = 'block';
}
function closeResultsModal() {
document.getElementById('resultsModal').style.display = 'none';
}

227
script.js Normal file
View File

@ -0,0 +1,227 @@
const STORAGE_KEY = 'turnierplaner_data';
// Daten laden beim Start
document.addEventListener('DOMContentLoaded', () => {
loadData();
});
function addTeam(league) {
const list = document.getElementById(`${league}-list`);
// Leere-Nachricht entfernen wenn vorhanden
const emptyMsg = list.querySelector('.empty-message');
if (emptyMsg) {
emptyMsg.remove();
}
const teamEntry = document.createElement('div');
teamEntry.className = 'team-entry';
teamEntry.innerHTML = `
<div class="team-info">
<input type="text" placeholder="Teamname" class="team-name">
<input type="text" placeholder="Vereinsname" class="team-club">
<input type="text" placeholder="Schlachtruf" class="team-motto">
</div>
<button class="delete-btn" onclick="deleteTeam(this)">Löschen</button>
`;
list.appendChild(teamEntry);
updateTeamCount(league);
saveData();
// Focus auf das erste Input-Feld
teamEntry.querySelector('.team-name').focus();
}
function deleteTeam(button) {
const teamEntry = button.parentElement;
const list = teamEntry.parentElement;
teamEntry.remove();
// Bestimme welche Liga
const league = list.id.replace('-list', '');
updateTeamCount(league);
saveData();
// Zeige Leere-Nachricht wenn keine Teams mehr
if (list.children.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.className = 'empty-message';
emptyMsg.textContent = 'Keine Teams hinzugefügt. Klicke auf + um zu beginnen!';
list.appendChild(emptyMsg);
}
}
function saveData() {
const data = {
fieldCount: document.getElementById('fieldCount').value,
bundesliga: getTeamsFromList('bundesliga'),
champions: getTeamsFromList('champions')
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
function getTeamsFromList(league) {
const list = document.getElementById(`${league}-list`);
const teams = [];
list.querySelectorAll('.team-entry').forEach(entry => {
teams.push({
name: entry.querySelector('.team-name').value,
club: entry.querySelector('.team-club').value,
motto: entry.querySelector('.team-motto').value
});
});
return teams;
}
function loadData() {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return;
const data = JSON.parse(stored);
// Lade Feldanzahl
document.getElementById('fieldCount').value = data.fieldCount || 1;
// Lade Teams
loadTeamsToList('bundesliga', data.bundesliga || []);
loadTeamsToList('champions', data.champions || []);
// Event listener für Auto-Save
document.getElementById('fieldCount').addEventListener('change', saveData);
document.addEventListener('input', (e) => {
if (e.target.classList.contains('team-name') ||
e.target.classList.contains('team-club') ||
e.target.classList.contains('team-motto')) {
saveData();
}
});
// Update Team-Anzahl
updateTeamCount('bundesliga');
updateTeamCount('champions');
}
function loadTeamsToList(league, teams) {
const list = document.getElementById(`${league}-list`);
list.innerHTML = '';
if (teams.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.className = 'empty-message';
emptyMsg.textContent = 'Keine Teams hinzugefügt. Klicke auf + um zu beginnen!';
list.appendChild(emptyMsg);
return;
}
teams.forEach(team => {
const teamEntry = document.createElement('div');
teamEntry.className = 'team-entry';
teamEntry.innerHTML = `
<div class="team-info">
<input type="text" placeholder="Teamname" class="team-name" value="${escapeHtml(team.name)}">
<input type="text" placeholder="Vereinsname" class="team-club" value="${escapeHtml(team.club)}">
<input type="text" placeholder="Schlachtruf" class="team-motto" value="${escapeHtml(team.motto)}">
</div>
<button class="delete-btn" onclick="deleteTeam(this)">Löschen</button>
`;
list.appendChild(teamEntry);
});
// Event listener für Auto-Save
document.addEventListener('input', (e) => {
if (e.target.classList.contains('team-name') ||
e.target.classList.contains('team-club') ||
e.target.classList.contains('team-motto')) {
saveData();
}
});
// Update Team-Anzahl
updateTeamCount(league);
}
function updateTeamCount(league) {
const list = document.getElementById(`${league}-list`);
const countElement = document.getElementById(`${league}-count`);
const teamCount = list.querySelectorAll('.team-entry').length;
const teamText = teamCount === 1 ? 'Team' : 'Teams';
countElement.textContent = `${teamCount} ${teamText}`;
}
function exportData() {
const data = {
fieldCount: document.getElementById('fieldCount').value,
bundesliga: getTeamsFromList('bundesliga'),
champions: getTeamsFromList('champions')
};
const outputDiv = document.getElementById('export-output');
outputDiv.textContent = JSON.stringify(data, null, 2);
outputDiv.style.display = 'block';
// Download als JSON-Datei
const jsonString = JSON.stringify(data, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `turnierplaner_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
function importData() {
document.getElementById('import-file').click();
}
function handleFileImport(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
document.getElementById('fieldCount').value = data.fieldCount || 1;
loadTeamsToList('bundesliga', data.bundesliga || []);
loadTeamsToList('champions', data.champions || []);
saveData();
alert('✅ Daten erfolgreich importiert!');
} catch (error) {
alert('❌ Fehler beim Importieren der Datei: ' + error.message);
}
};
reader.readAsText(file);
// Reset file input
event.target.value = '';
}
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
function navigateToPlanning() {
const data = {
fieldCount: document.getElementById('fieldCount').value,
bundesliga: getTeamsFromList('bundesliga'),
champions: getTeamsFromList('champions')
};
// Speichere in localStorage falls noch nicht gespeichert
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
// Navigiere zur Planungsseite
window.location.href = 'planning.html';
}

300
styles.css Normal file
View File

@ -0,0 +1,300 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
height: 100vh;
padding: 15px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
h1 {
text-align: center;
color: white;
margin-bottom: 15px;
font-size: 2em;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
flex-shrink: 0;
}
.settings {
background: white;
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
.settings-group {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.settings-group label {
font-weight: 600;
color: #333;
font-size: 1.1em;
}
.settings-group input {
padding: 12px 15px;
border: 2px solid #2ecc71;
border-radius: 4px;
font-size: 1.1em;
width: 200px;
min-height: 44px;
}
.settings-group input:focus {
outline: none;
border-color: #27ae60;
box-shadow: 0 0 0 3px rgba(46, 204, 113, 0.1);
}
.leagues-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 15px;
flex: 1;
overflow: hidden;
}
@media (max-width: 768px) {
.leagues-container {
grid-template-columns: 1fr;
}
}
@media (max-height: 600px) {
.leagues-container {
grid-template-columns: 1fr 1fr;
}
}
.league {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
height: 100%;
}
.league-header {
padding: 15px;
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
flex-shrink: 0;
}
.league-title {
display: flex;
flex-direction: column;
gap: 5px;
}
.league-title h2 {
margin: 0;
font-size: 1.3em;
}
.team-count {
font-size: 0.85em;
opacity: 0.9;
}
.add-team-btn {
background: white;
color: #27ae60;
border: none;
width: 50px;
height: 50px;
border-radius: 50%;
font-size: 1.8em;
cursor: pointer;
font-weight: bold;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
touch-action: manipulation;
}
.add-team-btn:hover {
background: #f0f0f0;
transform: scale(1.1);
}
.teams-list {
padding: 15px;
overflow-y: auto;
flex: 1;
-webkit-overflow-scrolling: touch;
}
.team-entry {
background: #f8f9fa;
padding: 15px;
margin-bottom: 15px;
border-radius: 6px;
border-left: 4px solid #2ecc71;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.team-entry:last-child {
margin-bottom: 0;
}
.team-info {
flex: 1;
}
.team-entry input {
width: 100%;
padding: 10px 12px;
margin-bottom: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1em;
font-family: inherit;
min-height: 44px;
}
.team-entry input:last-child {
margin-bottom: 0;
}
.team-entry input:focus {
outline: none;
border-color: #2ecc71;
box-shadow: 0 0 0 3px rgba(46, 204, 113, 0.1);
}
.team-entry input::placeholder {
color: #999;
}
.delete-btn {
background: #ff6b6b;
color: white;
border: none;
padding: 10px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 0.95em;
transition: background 0.3s;
white-space: nowrap;
min-height: 44px;
touch-action: manipulation;
}
.delete-btn:hover {
background: #ff5252;
}
.empty-message {
text-align: center;
color: #999;
padding: 40px 20px;
font-style: italic;
}
.export-section {
background: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.export-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
flex: 1;
}
.export-btn {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
font-size: 1em;
cursor: pointer;
font-weight: 600;
transition: transform 0.3s;
min-height: 44px;
touch-action: manipulation;
}
.export-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(46, 204, 113, 0.4);
}
.next-btn {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
border: none;
padding: 12px 32px;
border-radius: 4px;
font-size: 1.1em;
cursor: pointer;
font-weight: 600;
transition: transform 0.3s;
min-height: 44px;
touch-action: manipulation;
white-space: nowrap;
}
.next-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(46, 204, 113, 0.4);
}
.export-output {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #ddd;
font-family: 'Courier New', monospace;
font-size: 0.9em;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}