From f67d869005d914c2dc2655c8bf7009b22c867432 Mon Sep 17 00:00:00 2001 From: Marc Wieland Date: Wed, 7 Jan 2026 23:42:36 +0100 Subject: [PATCH] initial commit --- .dockerignore | 16 + .github/copilot-instructions.md | 112 +++++ DOCKER.md | 101 ++++ Dockerfile | 18 + docker-compose.yml | 17 + index.html | 64 +++ nginx.conf | 28 ++ planning.html | 721 ++++++++++++++++++++++++++++ planning.js | 799 ++++++++++++++++++++++++++++++++ script.js | 227 +++++++++ styles.css | 300 ++++++++++++ 11 files changed, 2403 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/copilot-instructions.md create mode 100644 DOCKER.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 nginx.conf create mode 100644 planning.html create mode 100644 planning.js create mode 100644 script.js create mode 100644 styles.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..51251d5 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..fa972bf --- /dev/null +++ b/.github/copilot-instructions.md @@ -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 `` ([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 diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..5a3f027 --- /dev/null +++ b/DOCKER.md @@ -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 +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..83545e1 --- /dev/null +++ b/Dockerfile @@ -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;"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b8b785b --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/index.html b/index.html new file mode 100644 index 0000000..4609b43 --- /dev/null +++ b/index.html @@ -0,0 +1,64 @@ + + + + + + Turnierplaner - Team-Eingabe + + + +
+

🏐 NVJ Turnierplaner

+ +
+
+ + +
+
+ +
+ +
+
+
+

Bundesliga

+
0 Teams
+
+ +
+
+
Keine Teams hinzugefügt. Klicke auf + um zu beginnen!
+
+
+ + +
+
+
+

Champions League

+
0 Teams
+
+ +
+
+
Keine Teams hinzugefügt. Klicke auf + um zu beginnen!
+
+
+
+ +
+
+ + + +
+ +
+ +
+
+ + + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..3279755 --- /dev/null +++ b/nginx.conf @@ -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"; + } +} diff --git a/planning.html b/planning.html new file mode 100644 index 0000000..044eddb --- /dev/null +++ b/planning.html @@ -0,0 +1,721 @@ + + + + + + Turnierplaner - Planung + + + + +
+
+ +

🏐 Turnierplanung

+
+ + + +
+ +
+
+

Bundesliga

+
+
+
+
+
Wartende Teams:
+
+
+
+
+ + +
+
+

Champions League

+
+
+
+
+
Wartende Teams:
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + diff --git a/planning.js b/planning.js new file mode 100644 index 0000000..e82e591 --- /dev/null +++ b/planning.js @@ -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 = `
Feld ${fieldNum}:
`; + 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 = ` +
+
${team1.name || '-'}
+ +
+ `; + 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 = ` +
+ +
${team2.name || '-'}
+
+ `; + 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 = '
Alle Teams spielen
'; + } 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 = ` +
Feld ${i}
+
${team.name || '-'}
+ `; + + 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 = '
Alle Teams spielen
'; + } 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 = 'Team'; + 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'; +} \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 0000000..48c549a --- /dev/null +++ b/script.js @@ -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 = ` +
+ + + +
+ + `; + + 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 = ` +
+ + + +
+ + `; + 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 = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + 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'; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..e5a3d29 --- /dev/null +++ b/styles.css @@ -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; +}