diff --git a/lib/achievementSystem.js b/lib/achievementSystem.js index 66467da..b255509 100644 --- a/lib/achievementSystem.js +++ b/lib/achievementSystem.js @@ -58,6 +58,9 @@ class AchievementSystem { */ async loadPlayerAchievements(playerId) { try { + // Initialisiere immer eine leere Map für den Spieler + this.playerAchievements.set(playerId, new Map()); + const result = await pool.query(` SELECT pa.achievement_id, pa.progress, pa.is_completed, pa.earned_at FROM player_achievements pa @@ -66,7 +69,6 @@ class AchievementSystem { `, [playerId]); // Gruppiere nach achievement_id und zähle Completions - this.playerAchievements.set(playerId, new Map()); const achievementCounts = new Map(); result.rows.forEach(pa => { @@ -87,6 +89,7 @@ class AchievementSystem { }); }); + console.log(`📋 ${result.rows.length} Achievements für Spieler ${playerId} geladen`); return true; } catch (error) { console.error(`❌ Fehler beim Laden der Spieler-Achievements für ${playerId}:`, error); @@ -122,6 +125,12 @@ class AchievementSystem { async checkImmediateAchievements(playerId) { console.log(`⚡ Prüfe sofortige Achievements für Spieler ${playerId}...`); + // Lade alle Achievements (falls noch nicht geladen) + if (this.achievements.size === 0) { + console.log('📋 Lade alle Achievements...'); + await this.loadAchievements(); + } + // Lade Spieler-Achievements await this.loadPlayerAchievements(playerId); @@ -216,6 +225,17 @@ class AchievementSystem { for (const achievement of achievements) { if (this.isAchievementCompleted(playerId, achievement.id)) continue; + // Prüfe ob das Achievement heute bereits vergeben wurde + const alreadyEarnedToday = await pool.query(` + SELECT COUNT(*) as count + FROM player_achievements pa + WHERE pa.player_id = $1 + AND pa.achievement_id = $2 + AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE + `, [playerId, achievement.id]); + + if (parseInt(alreadyEarnedToday.rows[0].count) > 0) continue; + if (attemptsToday >= achievement.condition_value) { await this.awardAchievement(playerId, achievement, attemptsToday, newAchievements); } @@ -322,6 +342,17 @@ class AchievementSystem { for (const achievement of achievements) { if (this.isAchievementCompleted(playerId, achievement.id)) continue; + // Prüfe ob das Achievement heute bereits vergeben wurde + const alreadyEarnedToday = await pool.query(` + SELECT COUNT(*) as count + FROM player_achievements pa + WHERE pa.player_id = $1 + AND pa.achievement_id = $2 + AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE + `, [playerId, achievement.id]); + + if (parseInt(alreadyEarnedToday.rows[0].count) > 0) continue; + // Prüfe ob Spieler zu dieser Zeit gespielt hat const hasPlayed = await this.checkTimeBasedPlay(playerId, timeAchievement.condition); @@ -510,7 +541,18 @@ class AchievementSystem { const achievement = Array.from(this.achievements.values()) .find(a => a.category === 'best_time' && a.condition_type === 'daily_best'); - if (!achievement || this.isAchievementCompleted(playerId, achievement.id)) return; + if (!achievement) return; + + // Prüfe ob das Achievement heute bereits vergeben wurde + const alreadyEarnedToday = await pool.query(` + SELECT COUNT(*) as count + FROM player_achievements pa + WHERE pa.player_id = $1 + AND pa.achievement_id = $2 + AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE + `, [playerId, achievement.id]); + + if (parseInt(alreadyEarnedToday.rows[0].count) > 0) return; // Hole beste Zeit des Spielers heute const playerResult = await pool.query(` @@ -542,14 +584,25 @@ class AchievementSystem { const achievement = Array.from(this.achievements.values()) .find(a => a.category === 'best_time' && a.condition_type === 'weekly_best'); - if (!achievement || this.isAchievementCompleted(playerId, achievement.id)) return; + if (!achievement) return; - // Berechne Wochenstart (Montag) + // Prüfe ob das Achievement diese Woche bereits vergeben wurde const currentDateObj = new Date(currentDate); const dayOfWeek = currentDateObj.getDay(); const weekStart = new Date(currentDateObj); weekStart.setDate(currentDateObj.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)); const weekStartStr = weekStart.toISOString().split('T')[0]; + + const alreadyEarnedThisWeek = await pool.query(` + SELECT COUNT(*) as count + FROM player_achievements pa + WHERE pa.player_id = $1 + AND pa.achievement_id = $2 + AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') >= $3 + AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') <= $4 + `, [playerId, achievement.id, weekStartStr, currentDate]); + + if (parseInt(alreadyEarnedThisWeek.rows[0].count) > 0) return; // Hole beste Zeit des Spielers diese Woche const playerResult = await pool.query(` @@ -583,12 +636,24 @@ class AchievementSystem { const achievement = Array.from(this.achievements.values()) .find(a => a.category === 'best_time' && a.condition_type === 'monthly_best'); - if (!achievement || this.isAchievementCompleted(playerId, achievement.id)) return; + if (!achievement) return; // Berechne Monatsstart const currentDateObj = new Date(currentDate); const monthStart = new Date(currentDateObj.getFullYear(), currentDateObj.getMonth(), 1); const monthStartStr = monthStart.toISOString().split('T')[0]; + + // Prüfe ob das Achievement diesen Monat bereits vergeben wurde + const alreadyEarnedThisMonth = await pool.query(` + SELECT COUNT(*) as count + FROM player_achievements pa + WHERE pa.player_id = $1 + AND pa.achievement_id = $2 + AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') >= $3 + AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') <= $4 + `, [playerId, achievement.id, monthStartStr, currentDate]); + + if (parseInt(alreadyEarnedThisMonth.rows[0].count) > 0) return; // Hole beste Zeit des Spielers diesen Monat const playerResult = await pool.query(` @@ -656,7 +721,10 @@ class AchievementSystem { // Für einmalige Achievements prüfen wir, ob sie bereits erreicht wurden const playerAchievements = this.playerAchievements.get(playerId); - if (!playerAchievements) return false; + if (!playerAchievements) { + console.log(`⚠️ Player achievements not loaded for ${playerId}, assuming not completed`); + return false; + } return playerAchievements.has(achievementId); } diff --git a/public/admin-dashboard.html b/public/admin-dashboard.html index 5bb7bf5..bcc6aa3 100644 --- a/public/admin-dashboard.html +++ b/public/admin-dashboard.html @@ -128,6 +128,13 @@ + +
+

🏆 Achievement-Verwaltung

+

Verwalte Achievements und Spieler-Achievements

+ +
+

📊 System-Informationen

diff --git a/public/css/admin-dashboard.css b/public/css/admin-dashboard.css index 788a37f..22b70f8 100644 --- a/public/css/admin-dashboard.css +++ b/public/css/admin-dashboard.css @@ -585,4 +585,169 @@ body { justify-content: center; gap: 1.5rem; } +} + +/* Achievement Management Styles */ +.achievement-controls { + display: flex; + gap: 10px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.status-badge { + padding: 4px 8px; + border-radius: 12px; + font-size: 0.8em; + font-weight: 500; +} + +.status-badge.active { + background: #4ade80; + color: #000; +} + +.status-badge.inactive { + background: #6b7280; + color: #fff; +} + +.progress-bar { + position: relative; + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; + height: 20px; + overflow: hidden; + min-width: 100px; +} + +.progress-fill { + background: linear-gradient(90deg, #4ade80, #22c55e); + height: 100%; + transition: width 0.3s ease; +} + +.progress-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 0.8em; + font-weight: 500; + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); +} + +.player-achievements { + max-height: 70vh; + overflow-y: auto; +} + +.achievement-stats { + display: flex; + gap: 20px; + margin-bottom: 20px; + padding: 15px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.stat-item { + font-size: 0.9em; +} + +.achievements-list { + display: grid; + gap: 15px; +} + +.achievement-item { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 15px; + transition: all 0.3s ease; +} + +.achievement-item.completed { + border-color: #4ade80; + background: rgba(74, 222, 128, 0.1); +} + +.achievement-item.not-completed { + border-color: #6b7280; + background: rgba(107, 114, 128, 0.1); +} + +.achievement-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.achievement-icon { + font-size: 1.5em; +} + +.achievement-name { + font-weight: 600; + flex: 1; +} + +.achievement-status { + font-size: 1.2em; +} + +.achievement-details p { + margin-bottom: 10px; + color: rgba(255, 255, 255, 0.8); + line-height: 1.4; +} + +.achievement-meta { + display: flex; + gap: 15px; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.achievement-meta span { + font-size: 0.8em; + color: rgba(255, 255, 255, 0.6); + background: rgba(255, 255, 255, 0.1); + padding: 2px 8px; + border-radius: 4px; +} + +.achievement-actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.achievement-actions .btn { + padding: 6px 12px; + font-size: 0.8em; +} + +@media (max-width: 768px) { + .achievement-controls { + flex-direction: column; + } + + .achievement-stats { + flex-direction: column; + gap: 10px; + } + + .achievement-meta { + flex-direction: column; + gap: 5px; + } + + .achievement-actions { + justify-content: center; + } } \ No newline at end of file diff --git a/public/css/dashboard.css b/public/css/dashboard.css index 15d8adf..110b3d7 100644 --- a/public/css/dashboard.css +++ b/public/css/dashboard.css @@ -1552,6 +1552,226 @@ input:checked+.toggle-slider:before { min-width: 120px; } +/* Analytics and Statistics Styles */ +.analytics-section, .statistics-section { + margin: 2rem 0; + padding: 1.5rem; + background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%); + border-radius: 10px; + border: 1px solid #34495e; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +.section-header { + text-align: center; + margin-bottom: 2rem; +} + +.section-header h2 { + color: #ecf0f1; + margin-bottom: 0.5rem; + font-size: 2rem; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.section-header p { + color: #bdc3c7; + font-size: 1.1rem; +} + +.analytics-grid, .statistics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} + +.analytics-card, .statistics-card { + background: linear-gradient(135deg, #34495e 0%, #2c3e50 100%); + padding: 1.5rem; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + border: 1px solid #34495e; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.analytics-card:hover, .statistics-card:hover { + transform: translateY(-2px); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4); +} + +.analytics-card h3, .statistics-card h3 { + color: #ecf0f1; + margin-bottom: 1rem; + font-size: 1.2rem; + border-bottom: 2px solid #3498db; + padding-bottom: 0.5rem; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +/* Mini Stats in Cards */ +.analytics-stats, .statistics-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 1rem; + margin-bottom: 1rem; +} + +.mini-stat { + text-align: center; + padding: 0.5rem; + background: rgba(52, 73, 94, 0.6); + border-radius: 5px; + border: 1px solid #34495e; +} + +.mini-stat-number { + font-size: 1.2rem; + font-weight: bold; + color: #ecf0f1; + margin-bottom: 0.25rem; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.mini-stat-label { + font-size: 0.8rem; + color: #bdc3c7; +} + +/* Trend Stats */ +.trend-stats, .activity-stats, .monthly-stats, .consistency-stats, .progress-stats { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.trend-item, .activity-item, .monthly-item, .consistency-item, .progress-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem; + background: rgba(52, 73, 94, 0.6); + border-radius: 5px; + border: 1px solid #34495e; + margin-bottom: 0.5rem; +} + +.trend-label, .activity-label, .monthly-label, .consistency-label, .progress-label { + font-weight: 500; + color: #bdc3c7; +} + +.trend-value, .activity-value, .monthly-value, .consistency-value, .progress-value { + font-weight: bold; + color: #ecf0f1; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +/* Personal Records */ +.personal-records { + max-height: 200px; + overflow-y: auto; +} + +.record-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem; + margin-bottom: 0.5rem; + background: rgba(52, 73, 94, 0.6); + border-radius: 5px; + border-left: 4px solid #3498db; + border: 1px solid #34495e; +} + +.record-rank { + font-weight: bold; + color: #e74c3c; + font-size: 1.1rem; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.record-time { + font-weight: bold; + color: #ecf0f1; + font-family: 'Courier New', monospace; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.record-location { + color: #bdc3c7; + font-size: 0.9rem; +} + +/* Location Performance */ +.location-stats { + max-height: 200px; + overflow-y: auto; +} + +.location-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem; + margin-bottom: 0.5rem; + background: rgba(52, 73, 94, 0.6); + border-radius: 5px; + border: 1px solid #34495e; +} + +.location-name { + font-weight: 500; + color: #ecf0f1; +} + +.location-best { + font-weight: bold; + color: #27ae60; + font-family: 'Courier New', monospace; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.location-runs { + color: #bdc3c7; + font-size: 0.9rem; +} + +/* Ranking Stats */ +.ranking-stats { + max-height: 200px; + overflow-y: auto; +} + +.ranking-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem; + margin-bottom: 0.5rem; + background: rgba(52, 73, 94, 0.6); + border-radius: 5px; + border: 1px solid #34495e; +} + +.ranking-category { + font-weight: 500; + color: #ecf0f1; +} + +.ranking-position { + font-weight: bold; + color: #e74c3c; + font-size: 1.1rem; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.ranking-total { + color: #bdc3c7; + font-size: 0.9rem; +} + /* Responsive Settings */ @media (max-width: 768px) { .setting-item { diff --git a/public/dashboard.html b/public/dashboard.html index 54b9f6f..0e98516 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -104,9 +104,26 @@
-
+

📊 Analytics

-

Verfolge deine Leistung und überwache wichtige Metriken. Dieser Abschnitt wird detaillierte Analysen anzeigen, sobald wir die Funktion implementieren.

+ +

Verfolge deine Leistung und überwache wichtige Metriken.

+
@@ -121,9 +138,26 @@
-
+

📊 Statistiken

-

Hier werden bald detaillierte Statistiken zu deinen Läufen angezeigt - beste Zeiten, Verbesserungen und Vergleiche mit anderen Spielern.

+ +

Detaillierte Statistiken zu deinen Läufen - beste Zeiten, Verbesserungen und Vergleiche.

+
@@ -189,6 +223,139 @@
+ + + + + +
diff --git a/public/js/admin-dashboard.js b/public/js/admin-dashboard.js index 5ff5662..1792881 100644 --- a/public/js/admin-dashboard.js +++ b/public/js/admin-dashboard.js @@ -317,7 +317,7 @@ function displayRunsTable(runs) { function displayLocationsTable(locations) { let html = '
'; - html += ''; + html += ''; locations.forEach(location => { html += ` @@ -325,6 +325,7 @@ function displayLocationsTable(locations) { +
IDNameLatitudeLongitudeErstelltAktionen
IDNameLatitudeLongitudeMindestzeit (s)ErstelltAktionen
${location.name} ${location.latitude} ${location.longitude}${location.time_threshold ? (typeof location.time_threshold === 'object' ? location.time_threshold.seconds : location.time_threshold).toFixed(3) : '-'} ${new Date(location.created_at).toLocaleDateString('de-DE')} @@ -393,6 +394,15 @@ function filterData() { case 'adminusers': displayAdminUsersTable(filteredData); break; + case 'achievements': + if (currentAchievementMode === 'achievements') { + currentAchievements = filteredData; + displayAchievements(); + } else { + currentPlayers = filteredData; + displayPlayers(); + } + break; } } @@ -413,6 +423,13 @@ function refreshData() { case 'system': loadSystemInfo(); break; + case 'achievements': + if (currentAchievementMode === 'achievements') { + loadAchievements(); + } else { + loadPlayers(); + } + break; } } @@ -556,6 +573,10 @@ async function handleAddSubmit(e) { successMessage = isEdit ? 'Lauf erfolgreich aktualisiert' : 'Lauf erfolgreich hinzugefügt'; method = isEdit ? 'PUT' : 'POST'; break; + case 'achievements': + // Handle achievement form submission + await handleAchievementSubmit(formData); + return; default: showError('Unbekannter Datentyp'); return; @@ -1196,7 +1217,552 @@ function displayBlacklistStats(stats) { statsDiv.innerHTML = html; } +// ==================== ACHIEVEMENT MANAGEMENT ==================== +let currentAchievementMode = 'achievements'; // 'achievements' or 'players' +let currentAchievements = []; +let currentPlayers = []; +// Show achievement management +async function showAchievementManagement() { + currentDataType = 'achievements'; + currentAchievementMode = 'achievements'; + + document.getElementById('dataTitle').textContent = '🏆 Achievement-Verwaltung'; + document.getElementById('dataSection').style.display = 'block'; + + // Update search placeholder + document.getElementById('searchInput').placeholder = 'Achievements durchsuchen...'; + + await loadAchievements(); +} +// Load all achievements +async function loadAchievements() { + try { + const response = await fetch('/api/v1/admin/achievements'); + const result = await response.json(); + + if (result.success) { + currentAchievements = result.data; + currentData = result.data; // Set for filtering + displayAchievements(); + } else { + showError('Fehler beim Laden der Achievements: ' + result.message); + } + } catch (error) { + console.error('Error loading achievements:', error); + showError('Fehler beim Laden der Achievements'); + } +} + +// Display achievements in table +function displayAchievements() { + const content = document.getElementById('dataContent'); + + if (currentAchievements.length === 0) { + content.innerHTML = '
Keine Achievements gefunden
'; + return; + } + + let html = ` +
+ + +
+
+ + + + + + + + + + + + + + + `; + + currentAchievements.forEach(achievement => { + const statusClass = achievement.is_active ? 'active' : 'inactive'; + const statusText = achievement.is_active ? 'Aktiv' : 'Inaktiv'; + const multipleText = achievement.can_be_earned_multiple_times ? 'Ja' : 'Nein'; + + html += ` + + + + + + + + + + + `; + }); + + html += ` + +
StatusIconNameKategorieBedingungPunkteMehrmalsAktionen
${statusText}${achievement.icon || '🏆'} + ${achievement.name} + ${achievement.name_en ? `
${achievement.name_en}` : ''} +
${achievement.category}${achievement.condition_type}: ${achievement.condition_value}${achievement.points}${multipleText} + + +
+
+ `; + + content.innerHTML = html; +} + +// Handle achievement form submission +async function handleAchievementSubmit(formData) { + const isEdit = formData.has('achievement_id'); + const url = isEdit ? + `/api/v1/admin/achievements/${formData.get('achievement_id')}` : + '/api/v1/admin/achievements'; + const method = isEdit ? 'PUT' : 'POST'; + + const data = { + name: formData.get('name'), + name_en: formData.get('name_en') || null, + description: formData.get('description'), + description_en: formData.get('description_en') || null, + category: formData.get('category'), + condition_type: formData.get('condition_type'), + condition_value: parseInt(formData.get('condition_value')), + icon: formData.get('icon') || '🏆', + points: parseInt(formData.get('points')) || 10, + is_active: formData.has('is_active'), + can_be_earned_multiple_times: formData.has('can_be_earned_multiple_times') + }; + + try { + const response = await fetch(url, { + method: method, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + const result = await response.json(); + + if (result.success) { + showSuccess(result.message); + closeModal(); + await loadAchievements(); + } else { + showError('Fehler beim Speichern: ' + result.message); + } + } catch (error) { + console.error('Error saving achievement:', error); + showError('Fehler beim Speichern des Achievements'); + } +} + +// Toggle between achievements and players view +async function toggleAchievementMode() { + if (currentAchievementMode === 'achievements') { + currentAchievementMode = 'players'; + document.getElementById('dataTitle').textContent = '👥 Spieler-Achievements'; + document.getElementById('searchInput').placeholder = 'Spieler durchsuchen...'; + await loadPlayers(); + } else { + currentAchievementMode = 'achievements'; + document.getElementById('dataTitle').textContent = '🏆 Achievement-Verwaltung'; + document.getElementById('searchInput').placeholder = 'Achievements durchsuchen...'; + await loadAchievements(); + } +} + +// Load all players with achievement statistics +async function loadPlayers() { + try { + const response = await fetch('/api/v1/admin/achievements/players'); + const result = await response.json(); + + if (result.success) { + currentPlayers = result.data; + currentData = result.data; // Set for filtering + displayPlayers(); + } else { + showError('Fehler beim Laden der Spieler: ' + result.message); + } + } catch (error) { + console.error('Error loading players:', error); + showError('Fehler beim Laden der Spieler'); + } +} + +// Display players in table +function displayPlayers() { + const content = document.getElementById('dataContent'); + + if (currentPlayers.length === 0) { + content.innerHTML = '
Keine Spieler gefunden
'; + return; + } + + let html = ` +
+ +
+
+ + + + + + + + + + + + + `; + + currentPlayers.forEach(player => { + const progressPercentage = player.completion_percentage || 0; + const progressBar = ` +
+
+ ${progressPercentage}% +
+ `; + + html += ` + + + + + + + + + `; + }); + + html += ` + +
SpielerAbgeschlossenGesamtFortschrittPunkteAktionen
${player.firstname} ${player.lastname}${player.completed_achievements}${player.total_achievements}${progressBar}${player.total_points} + +
+
+ `; + + content.innerHTML = html; +} + +// Show add achievement modal +function showAddAchievementModal() { + const modal = document.getElementById('addModal'); + const modalTitle = document.getElementById('modalTitle'); + const formFields = document.getElementById('formFields'); + + modalTitle.textContent = 'Neues Achievement erstellen'; + + formFields.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ `; + + modal.style.display = 'block'; +} + +// Edit achievement +async function editAchievement(achievementId) { + const achievement = currentAchievements.find(a => a.id === achievementId); + if (!achievement) return; + + const modal = document.getElementById('addModal'); + const modalTitle = document.getElementById('modalTitle'); + const formFields = document.getElementById('formFields'); + + modalTitle.textContent = 'Achievement bearbeiten'; + + formFields.innerHTML = ` + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ `; + + modal.style.display = 'block'; +} + +// Delete achievement +function deleteAchievement(achievementId, achievementName) { + document.getElementById('confirmMessage').textContent = `Möchten Sie das Achievement "${achievementName}" wirklich deaktivieren?`; + document.getElementById('confirmYes').onclick = () => confirmDeleteAchievement(achievementId); + document.getElementById('confirmModal').style.display = 'block'; +} + +// Confirm delete achievement +async function confirmDeleteAchievement(achievementId) { + try { + const response = await fetch(`/api/v1/admin/achievements/${achievementId}`, { + method: 'DELETE' + }); + const result = await response.json(); + + if (result.success) { + showSuccess(result.message); + closeModal(); + await loadAchievements(); + } else { + showError('Fehler beim Löschen: ' + result.message); + } + } catch (error) { + console.error('Error deleting achievement:', error); + showError('Fehler beim Löschen des Achievements'); + } +} + +// View player achievements +async function viewPlayerAchievements(playerId, playerName) { + try { + const response = await fetch(`/api/v1/admin/achievements/players/${playerId}`); + const result = await response.json(); + + if (result.success) { + showPlayerAchievementsModal(result.player, result.data); + } else { + showError('Fehler beim Laden der Spieler-Achievements: ' + result.message); + } + } catch (error) { + console.error('Error loading player achievements:', error); + showError('Fehler beim Laden der Spieler-Achievements'); + } +} + +// Show player achievements modal +function showPlayerAchievementsModal(player, achievements) { + const modal = document.getElementById('addModal'); + const modalTitle = document.getElementById('modalTitle'); + const formFields = document.getElementById('formFields'); + + modalTitle.textContent = `Achievements von ${player.firstname} ${player.lastname}`; + + let html = ` +
+
+
+ Abgeschlossen: ${achievements.filter(a => a.is_completed).length} / ${achievements.length} +
+
+ Gesamtpunkte: ${achievements.filter(a => a.is_completed).reduce((sum, a) => sum + a.points, 0)} +
+
+
+ `; + + achievements.forEach(achievement => { + const statusClass = achievement.is_completed ? 'completed' : 'not-completed'; + const statusIcon = achievement.is_completed ? '✅' : '❌'; + const completionCount = achievement.completion_count || 0; + + html += ` +
+
+ ${achievement.icon} + ${achievement.name} + ${statusIcon} +
+
+

${achievement.description}

+
+ Kategorie: ${achievement.category} + Punkte: ${achievement.points} + ${achievement.is_completed ? `Erreicht: ${new Date(achievement.earned_at).toLocaleDateString('de-DE')}` : ''} + ${completionCount > 1 ? `Anzahl: ${completionCount}` : ''} +
+
+ ${!achievement.is_completed ? + `` : + `` + } +
+
+
+ `; + }); + + html += ` +
+
+ `; + + formFields.innerHTML = html; + modal.style.display = 'block'; +} + +// Award achievement to player +async function awardAchievement(playerId, achievementId, achievementName) { + try { + const response = await fetch(`/api/v1/admin/achievements/players/${playerId}/award`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + achievement_id: achievementId, + progress: 1 + }) + }); + const result = await response.json(); + + if (result.success) { + showSuccess(result.message); + // Refresh player achievements + await viewPlayerAchievements(playerId, ''); + } else { + showError('Fehler beim Vergeben: ' + result.message); + } + } catch (error) { + console.error('Error awarding achievement:', error); + showError('Fehler beim Vergeben des Achievements'); + } +} + +// Revoke achievement from player +async function revokeAchievement(playerId, achievementId, achievementName) { + if (!confirm(`Möchten Sie das Achievement "${achievementName}" wirklich entfernen?`)) { + return; + } + + try { + const response = await fetch(`/api/v1/admin/achievements/players/${playerId}/revoke`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + achievement_id: achievementId + }) + }); + const result = await response.json(); + + if (result.success) { + showSuccess(result.message); + // Refresh player achievements + await viewPlayerAchievements(playerId, ''); + } else { + showError('Fehler beim Entfernen: ' + result.message); + } + } catch (error) { + console.error('Error revoking achievement:', error); + showError('Fehler beim Entfernen des Achievements'); + } +} diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 463e539..7e39f0a 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -1177,6 +1177,266 @@ function displayAchievements() { achievementsGrid.innerHTML = achievementCards; } +// Initialize Analytics and Statistics event listeners +document.addEventListener('DOMContentLoaded', function() { + const analyticsCard = document.getElementById('analyticsCard'); + const statisticsCard = document.getElementById('statisticsCard'); + + if (analyticsCard) { + analyticsCard.addEventListener('click', showAnalytics); + console.log('Analytics card event listener added'); + } + + if (statisticsCard) { + statisticsCard.addEventListener('click', showStatistics); + console.log('Statistics card event listener added'); + } +}); + +// Analytics Functions +function showAnalytics() { + console.log('showAnalytics called'); + + // Hide other sections + const timesDisplay = document.getElementById('timesDisplay'); + const achievementsDisplay = document.getElementById('achievementsDisplay'); + const statisticsSection = document.getElementById('statisticsSection'); + + if (timesDisplay) timesDisplay.style.display = 'none'; + if (achievementsDisplay) achievementsDisplay.style.display = 'none'; + if (statisticsSection) statisticsSection.style.display = 'none'; + + // Show analytics section + const analyticsSection = document.getElementById('analyticsSection'); + if (analyticsSection) { + analyticsSection.style.display = 'block'; + console.log('Analytics section shown'); + } else { + console.error('Analytics section not found'); + } + + // Load analytics data + loadAnalyticsData(); +} + +function showStatistics() { + console.log('showStatistics called'); + + // Hide other sections + const timesDisplay = document.getElementById('timesDisplay'); + const achievementsDisplay = document.getElementById('achievementsDisplay'); + const analyticsSection = document.getElementById('analyticsSection'); + + if (timesDisplay) timesDisplay.style.display = 'none'; + if (achievementsDisplay) achievementsDisplay.style.display = 'none'; + if (analyticsSection) analyticsSection.style.display = 'none'; + + // Show statistics section + const statisticsSection = document.getElementById('statisticsSection'); + if (statisticsSection) { + statisticsSection.style.display = 'block'; + console.log('Statistics section shown'); + } else { + console.error('Statistics section not found'); + } + + // Load statistics data + loadStatisticsData(); +} + +async function loadAnalyticsData() { + try { + if (!currentPlayerId) { + console.error('No player ID available'); + return; + } + + // Load analytics data from API + const response = await fetch(`/api/v1/analytics/player/${currentPlayerId}`); + if (!response.ok) { + throw new Error('Failed to load analytics data'); + } + + const analyticsData = await response.json(); + displayAnalyticsData(analyticsData); + + // Update preview in main card + updateAnalyticsPreview(analyticsData); + + } catch (error) { + console.error('Error loading analytics data:', error); + // Show fallback data + displayAnalyticsFallback(); + } +} + +async function loadStatisticsData() { + try { + if (!currentPlayerId) { + console.error('No player ID available'); + return; + } + + // Load statistics data from API + const response = await fetch(`/api/v1/statistics/player/${currentPlayerId}`); + if (!response.ok) { + throw new Error('Failed to load statistics data'); + } + + const statisticsData = await response.json(); + displayStatisticsData(statisticsData); + + // Update preview in main card + updateStatisticsPreview(statisticsData); + + } catch (error) { + console.error('Error loading statistics data:', error); + // Show fallback data + displayStatisticsFallback(); + } +} + +function displayAnalyticsData(data) { + // Performance Trends + document.getElementById('avgTimeThisWeekDetail').textContent = formatTime(data.performance.avgTimeThisWeek); + document.getElementById('avgTimeLastWeek').textContent = formatTime(data.performance.avgTimeLastWeek); + document.getElementById('improvementDetail').textContent = data.performance.improvement + '%'; + + // Activity Stats + document.getElementById('runsToday').textContent = data.activity.runsToday + ' Läufe'; + document.getElementById('runsThisWeekDetail').textContent = data.activity.runsThisWeek + ' Läufe'; + document.getElementById('avgRunsPerDay').textContent = data.activity.avgRunsPerDay.toFixed(1); + + // Location Performance + displayLocationPerformance(data.locationPerformance); + + // Monthly Stats + document.getElementById('runsThisMonth').textContent = data.monthly.runsThisMonth + ' Läufe'; + document.getElementById('runsLastMonth').textContent = data.monthly.runsLastMonth + ' Läufe'; + document.getElementById('bestTimeThisMonth').textContent = formatTime(data.monthly.bestTimeThisMonth); +} + +function displayStatisticsData(data) { + // Personal Records + displayPersonalRecords(data.personalRecords); + + // Consistency Metrics + document.getElementById('averageTime').textContent = formatTime(data.consistency.averageTime); + document.getElementById('timeDeviation').textContent = formatTime(data.consistency.timeDeviation); + document.getElementById('consistencyScore').textContent = data.consistency.consistencyScore + '%'; + + // Ranking Stats + displayRankingStats(data.rankings); + + // Progress Stats + document.getElementById('totalRunsStats').textContent = data.progress.totalRuns; + document.getElementById('activeDays').textContent = data.progress.activeDays; + document.getElementById('locationsVisited').textContent = data.progress.locationsVisited; +} + +function displayLocationPerformance(locations) { + const container = document.getElementById('locationPerformance'); + + if (!locations || locations.length === 0) { + container.innerHTML = '

Keine Standort-Daten verfügbar

'; + return; + } + + const locationHTML = locations.map(location => ` +
+ ${location.name} +
+ ${formatTime(location.bestTime)} + (${location.runs} Läufe) +
+
+ `).join(''); + + container.innerHTML = locationHTML; +} + +function displayPersonalRecords(records) { + const container = document.getElementById('personalRecords'); + + if (!records || records.length === 0) { + container.innerHTML = '

Keine Bestzeiten verfügbar

'; + return; + } + + const recordsHTML = records.map((record, index) => ` +
+
+ #${index + 1} + ${formatTime(record.time)} +
+ ${record.location} +
+ `).join(''); + + container.innerHTML = recordsHTML; +} + +function displayRankingStats(rankings) { + const container = document.getElementById('rankingStats'); + + if (!rankings || rankings.length === 0) { + container.innerHTML = '

Keine Ranglisten-Daten verfügbar

'; + return; + } + + const rankingsHTML = rankings.map(ranking => ` +
+ ${ranking.category} +
+ #${ranking.position} + von ${ranking.total} +
+
+ `).join(''); + + container.innerHTML = rankingsHTML; +} + +function updateAnalyticsPreview(data) { + document.getElementById('avgTimeThisWeek').textContent = formatTime(data.performance.avgTimeThisWeek); + document.getElementById('improvementThisWeek').textContent = data.performance.improvement + '%'; + document.getElementById('runsThisWeek').textContent = data.activity.runsThisWeek; + document.getElementById('analyticsPreview').style.display = 'block'; +} + +function updateStatisticsPreview(data) { + document.getElementById('personalBest').textContent = formatTime(data.personalRecords[0]?.time || 0); + document.getElementById('totalRunsCount').textContent = data.progress.totalRuns; + document.getElementById('rankPosition').textContent = data.rankings[0]?.position || '-'; + document.getElementById('statisticsPreview').style.display = 'block'; +} + +function displayAnalyticsFallback() { + // Show fallback data when API fails + document.getElementById('avgTimeThisWeekDetail').textContent = '--:--'; + document.getElementById('avgTimeLastWeek').textContent = '--:--'; + document.getElementById('improvementDetail').textContent = '+0.0%'; + document.getElementById('runsToday').textContent = '0 Läufe'; + document.getElementById('runsThisWeekDetail').textContent = '0 Läufe'; + document.getElementById('avgRunsPerDay').textContent = '0.0'; + document.getElementById('locationPerformance').innerHTML = '

Daten nicht verfügbar

'; + document.getElementById('runsThisMonth').textContent = '0 Läufe'; + document.getElementById('runsLastMonth').textContent = '0 Läufe'; + document.getElementById('bestTimeThisMonth').textContent = '--:--'; +} + +function displayStatisticsFallback() { + // Show fallback data when API fails + document.getElementById('personalRecords').innerHTML = '

Daten nicht verfügbar

'; + document.getElementById('averageTime').textContent = '--:--'; + document.getElementById('timeDeviation').textContent = '--:--'; + document.getElementById('consistencyScore').textContent = '0%'; + document.getElementById('rankingStats').innerHTML = '

Daten nicht verfügbar

'; + document.getElementById('totalRunsStats').textContent = '0'; + document.getElementById('activeDays').textContent = '0'; + document.getElementById('locationsVisited').textContent = '0'; +} + // Get achievement condition value for progress display function getAchievementConditionValue(achievementName) { const conditionMap = { diff --git a/routes/api.js b/routes/api.js index 85e51d7..74216a4 100644 --- a/routes/api.js +++ b/routes/api.js @@ -884,20 +884,24 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => { ); // Achievement-Überprüfung nach Zeit-Eingabe (JavaScript) - try { - const AchievementSystem = require('../lib/achievementSystem'); - const achievementSystem = new AchievementSystem(); - const newAchievements = await achievementSystem.checkImmediateAchievements(result.rows[0].player_id); - - if (newAchievements.length > 0) { - console.log(`🏆 ${newAchievements.length} neue Achievements für Spieler ${result.rows[0].player_id}:`); - newAchievements.forEach(achievement => { - console.log(` ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)`); - }); + if (result.rows[0].player_id) { + try { + const AchievementSystem = require('../lib/achievementSystem'); + const achievementSystem = new AchievementSystem(); + const newAchievements = await achievementSystem.checkImmediateAchievements(result.rows[0].player_id); + + if (newAchievements.length > 0) { + console.log(`🏆 ${newAchievements.length} neue Achievements für Spieler ${result.rows[0].player_id}:`); + newAchievements.forEach(achievement => { + console.log(` ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)`); + }); + } + } catch (achievementError) { + console.error('Fehler bei Achievement-Check:', achievementError); + // Achievement-Fehler sollen die Zeit-Eingabe nicht blockieren } - } catch (achievementError) { - console.error('Fehler bei Achievement-Check:', achievementError); - // Achievement-Fehler sollen die Zeit-Eingabe nicht blockieren + } else { + console.log('⚠️ Kein Spieler verknüpft, keine Achievement-Prüfung'); } // WebSocket-Event senden für Live-Updates @@ -2956,6 +2960,383 @@ router.get('/v1/public/push-status', async (req, res) => { } }); +// ==================== ANALYTICS HELPER FUNCTIONS ==================== + +async function getPerformanceTrends(playerId) { + const now = new Date(); + const startOfWeek = new Date(now.setDate(now.getDate() - now.getDay())); + const startOfLastWeek = new Date(startOfWeek.getTime() - 7 * 24 * 60 * 60 * 1000); + + // This week's average + const thisWeekResult = await pool.query(` + SELECT AVG(EXTRACT(EPOCH FROM recorded_time)) as avg_seconds + FROM times + WHERE player_id = $1 + AND created_at >= $2 + `, [playerId, startOfWeek]); + + // Last week's average + const lastWeekResult = await pool.query(` + SELECT AVG(EXTRACT(EPOCH FROM recorded_time)) as avg_seconds + FROM times + WHERE player_id = $1 + AND created_at >= $2 AND created_at < $3 + `, [playerId, startOfLastWeek, startOfWeek]); + + const avgTimeThisWeek = thisWeekResult.rows[0].avg_seconds || 0; + const avgTimeLastWeek = lastWeekResult.rows[0].avg_seconds || 0; + + const improvement = avgTimeLastWeek > 0 ? + ((avgTimeLastWeek - avgTimeThisWeek) / avgTimeLastWeek * 100) : 0; + + return { + avgTimeThisWeek: avgTimeThisWeek, + avgTimeLastWeek: avgTimeLastWeek, + improvement: Math.round(improvement * 10) / 10 + }; +} + +async function getActivityStats(playerId) { + const now = new Date(); + const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const startOfWeek = new Date(now.setDate(now.getDate() - now.getDay())); + + // Today's runs + const todayResult = await pool.query(` + SELECT COUNT(*) as count + FROM times + WHERE player_id = $1 + AND created_at >= $2 + `, [playerId, startOfDay]); + + // This week's runs + const weekResult = await pool.query(` + SELECT COUNT(*) as count + FROM times + WHERE player_id = $1 + AND created_at >= $2 + `, [playerId, startOfWeek]); + + // Average runs per day (last 30 days) + const avgResult = await pool.query(` + SELECT COUNT(*) / 30.0 as avg_runs + FROM times + WHERE player_id = $1 + AND created_at >= NOW() - INTERVAL '30 days' + `, [playerId]); + + return { + runsToday: parseInt(todayResult.rows[0].count), + runsThisWeek: parseInt(weekResult.rows[0].count), + avgRunsPerDay: parseFloat(avgResult.rows[0].avg_runs) || 0 + }; +} + +async function getLocationPerformance(playerId) { + const result = await pool.query(` + SELECT + l.name, + MIN(t.recorded_time) as best_time, + COUNT(t.id) as runs + FROM times t + JOIN locations l ON t.location_id = l.id + WHERE t.player_id = $1 + GROUP BY l.id, l.name + ORDER BY best_time ASC + LIMIT 10 + `, [playerId]); + + return result.rows.map(row => ({ + name: row.name, + bestTime: convertTimeToSeconds(row.best_time), + runs: parseInt(row.runs) + })); +} + +async function getMonthlyStats(playerId) { + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const endOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0); + + // This month's runs + const thisMonthResult = await pool.query(` + SELECT COUNT(*) as count + FROM times + WHERE player_id = $1 + AND created_at >= $2 + `, [playerId, startOfMonth]); + + // Last month's runs + const lastMonthResult = await pool.query(` + SELECT COUNT(*) as count + FROM times + WHERE player_id = $1 + AND created_at >= $2 AND created_at <= $3 + `, [playerId, startOfLastMonth, endOfLastMonth]); + + // Best time this month + const bestTimeResult = await pool.query(` + SELECT MIN(recorded_time) as best_time + FROM times + WHERE player_id = $1 + AND created_at >= $2 + `, [playerId, startOfMonth]); + + return { + runsThisMonth: parseInt(thisMonthResult.rows[0].count), + runsLastMonth: parseInt(lastMonthResult.rows[0].count), + bestTimeThisMonth: convertTimeToSeconds(bestTimeResult.rows[0].best_time) || 0 + }; +} + +async function getPersonalRecords(playerId) { + const result = await pool.query(` + SELECT + t.recorded_time, + l.name as location + FROM times t + JOIN locations l ON t.location_id = l.id + WHERE t.player_id = $1 + ORDER BY t.recorded_time ASC + LIMIT 5 + `, [playerId]); + + return result.rows.map(row => ({ + time: convertTimeToSeconds(row.recorded_time), + location: row.location + })); +} + +async function getConsistencyMetrics(playerId) { + const result = await pool.query(` + SELECT + AVG(EXTRACT(EPOCH FROM recorded_time)) as avg_seconds, + STDDEV(EXTRACT(EPOCH FROM recorded_time)) as stddev_seconds + FROM times + WHERE player_id = $1 + `, [playerId]); + + const avgSeconds = parseFloat(result.rows[0].avg_seconds) || 0; + const stddevSeconds = parseFloat(result.rows[0].stddev_seconds) || 0; + + // Consistency score: lower deviation = higher consistency + const consistencyScore = avgSeconds > 0 ? + Math.max(0, Math.min(100, (1 - (stddevSeconds / avgSeconds)) * 100)) : 0; + + return { + averageTime: avgSeconds, + timeDeviation: stddevSeconds, + consistencyScore: Math.round(consistencyScore) + }; +} + +async function getRankingStats(playerId) { + // Get player's best time + const bestTimeResult = await pool.query(` + SELECT MIN(recorded_time) as best_time + FROM times + WHERE player_id = $1 + `, [playerId]); + + const bestTime = bestTimeResult.rows[0].best_time; + if (!bestTime) { + return []; + } + + // Get rankings for different categories + const rankings = []; + + // Overall ranking + const overallResult = await pool.query(` + SELECT COUNT(*) + 1 as position + FROM times + WHERE recorded_time < $1 + `, [bestTime]); + + const totalPlayersResult = await pool.query(` + SELECT COUNT(DISTINCT player_id) as total + FROM times + `); + + rankings.push({ + category: 'Gesamt', + position: parseInt(overallResult.rows[0].position), + total: parseInt(totalPlayersResult.rows[0].total) + }); + + return rankings; +} + +async function getProgressStats(playerId) { + // Total runs + const totalRunsResult = await pool.query(` + SELECT COUNT(*) as count + FROM times + WHERE player_id = $1 + `, [playerId]); + + // Active days + const activeDaysResult = await pool.query(` + SELECT COUNT(DISTINCT DATE(created_at)) as count + FROM times + WHERE player_id = $1 + `, [playerId]); + + // Locations visited + const locationsResult = await pool.query(` + SELECT COUNT(DISTINCT location_id) as count + FROM times + WHERE player_id = $1 + `, [playerId]); + + return { + totalRuns: parseInt(totalRunsResult.rows[0].count), + activeDays: parseInt(activeDaysResult.rows[0].count), + locationsVisited: parseInt(locationsResult.rows[0].count) + }; +} + +// ==================== ANALYTICS ENDPOINTS ==================== + +/** + * @swagger + * /api/analytics/player/{playerId}: + * get: + * summary: Analytics-Daten für einen Spieler abrufen + * description: Ruft detaillierte Analytics-Daten für einen bestimmten Spieler ab + * tags: [Analytics] + * parameters: + * - in: path + * name: playerId + * required: true + * schema: + * type: string + * description: Spieler-ID + * responses: + * 200: + * description: Analytics-Daten erfolgreich abgerufen + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * 404: + * description: Spieler nicht gefunden + * 500: + * description: Interner Serverfehler + */ +router.get('/analytics/player/:playerId', async (req, res) => { + const { playerId } = req.params; + + try { + // Performance Trends + const performanceData = await getPerformanceTrends(playerId); + + // Activity Stats + const activityData = await getActivityStats(playerId); + + // Location Performance + const locationData = await getLocationPerformance(playerId); + + // Monthly Stats + const monthlyData = await getMonthlyStats(playerId); + + const analyticsData = { + performance: performanceData, + activity: activityData, + locationPerformance: locationData, + monthly: monthlyData + }; + + res.json({ + success: true, + data: analyticsData + }); + + } catch (error) { + console.error('Error fetching analytics data:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Laden der Analytics-Daten' + }); + } +}); + +// ==================== STATISTICS ENDPOINTS ==================== + +/** + * @swagger + * /api/statistics/player/{playerId}: + * get: + * summary: Statistiken für einen Spieler abrufen + * description: Ruft detaillierte Statistiken für einen bestimmten Spieler ab + * tags: [Statistics] + * parameters: + * - in: path + * name: playerId + * required: true + * schema: + * type: string + * description: Spieler-ID + * responses: + * 200: + * description: Statistiken erfolgreich abgerufen + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * 404: + * description: Spieler nicht gefunden + * 500: + * description: Interner Serverfehler + */ +router.get('/statistics/player/:playerId', async (req, res) => { + const { playerId } = req.params; + + try { + // Personal Records + const personalRecords = await getPersonalRecords(playerId); + + // Consistency Metrics + const consistencyData = await getConsistencyMetrics(playerId); + + // Ranking Stats + const rankingData = await getRankingStats(playerId); + + // Progress Stats + const progressData = await getProgressStats(playerId); + + const statisticsData = { + personalRecords: personalRecords, + consistency: consistencyData, + rankings: rankingData, + progress: progressData + }; + + res.json({ + success: true, + data: statisticsData + }); + + } catch (error) { + console.error('Error fetching statistics data:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Laden der Statistiken' + }); + } +}); + // ==================== ACHIEVEMENT ENDPOINTS ==================== /** @@ -3367,4 +3748,667 @@ router.post('/v1/private/update-player-settings', requireApiKey, async (req, res } }); +// ==================== ADMIN ACHIEVEMENT MANAGEMENT ==================== + +/** + * @swagger + * /api/admin/achievements: + * get: + * summary: Alle Achievements für Admin-Verwaltung abrufen + * description: Ruft alle Achievements (aktiv und inaktiv) für die Admin-Verwaltung ab + * tags: [Admin, Achievements] + * responses: + * 200: + * description: Liste aller Achievements + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: array + * items: + * $ref: '#/components/schemas/Achievement' + * 500: + * description: Server-Fehler + */ +// Get all achievements for admin management +router.get('/v1/admin/achievements', requireAdminAuth, async (req, res) => { + try { + const result = await pool.query(` + SELECT + id, name, name_en, description, description_en, category, + condition_type, condition_value, icon, points, is_active, + can_be_earned_multiple_times, created_at + FROM achievements + ORDER BY is_active DESC, category, points DESC + `); + + res.json({ + success: true, + data: result.rows + }); + } catch (error) { + console.error('Error fetching achievements for admin:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Laden der Achievements' + }); + } +}); + +/** + * @swagger + * /api/admin/achievements: + * post: + * summary: Neues Achievement erstellen + * description: Erstellt ein neues Achievement im System + * tags: [Admin, Achievements] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - description + * - category + * - condition_type + * - condition_value + * properties: + * name: + * type: string + * description: Name des Achievements + * name_en: + * type: string + * description: Englischer Name des Achievements + * description: + * type: string + * description: Beschreibung des Achievements + * description_en: + * type: string + * description: Englische Beschreibung des Achievements + * category: + * type: string + * description: Kategorie des Achievements + * condition_type: + * type: string + * description: Art der Bedingung + * condition_value: + * type: integer + * description: Wert der Bedingung + * icon: + * type: string + * description: Icon für das Achievement + * points: + * type: integer + * description: Punkte für das Achievement + * is_active: + * type: boolean + * description: Ob das Achievement aktiv ist + * can_be_earned_multiple_times: + * type: boolean + * description: Ob das Achievement mehrmals erreicht werden kann + * responses: + * 201: + * description: Achievement erfolgreich erstellt + * 400: + * description: Ungültige Eingabedaten + * 500: + * description: Server-Fehler + */ +// Create new achievement +router.post('/v1/admin/achievements', requireAdminAuth, async (req, res) => { + try { + const { + name, name_en, description, description_en, category, + condition_type, condition_value, icon, points, is_active, + can_be_earned_multiple_times + } = req.body; + + // Validate required fields + if (!name || !description || !category || !condition_type || condition_value === undefined) { + return res.status(400).json({ + success: false, + message: 'Name, Beschreibung, Kategorie, Bedingungstyp und Bedingungswert sind erforderlich' + }); + } + + const result = await pool.query(` + INSERT INTO achievements ( + name, name_en, description, description_en, category, + condition_type, condition_value, icon, points, is_active, + can_be_earned_multiple_times + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING * + `, [ + name, name_en || null, description, description_en || null, category, + condition_type, condition_value, icon || '🏆', points || 10, + is_active !== false, can_be_earned_multiple_times || false + ]); + + res.status(201).json({ + success: true, + message: 'Achievement erfolgreich erstellt', + data: result.rows[0] + }); + } catch (error) { + console.error('Error creating achievement:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Erstellen des Achievements' + }); + } +}); + +/** + * @swagger + * /api/admin/achievements/{id}: + * put: + * summary: Achievement aktualisieren + * description: Aktualisiert ein bestehendes Achievement + * tags: [Admin, Achievements] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: ID des Achievements + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * name_en: + * type: string + * description: + * type: string + * description_en: + * type: string + * category: + * type: string + * condition_type: + * type: string + * condition_value: + * type: integer + * icon: + * type: string + * points: + * type: integer + * is_active: + * type: boolean + * can_be_earned_multiple_times: + * type: boolean + * responses: + * 200: + * description: Achievement erfolgreich aktualisiert + * 404: + * description: Achievement nicht gefunden + * 500: + * description: Server-Fehler + */ +// Update achievement +router.put('/v1/admin/achievements/:id', requireAdminAuth, async (req, res) => { + try { + const { id } = req.params; + const { + name, name_en, description, description_en, category, + condition_type, condition_value, icon, points, is_active, + can_be_earned_multiple_times + } = req.body; + + const result = await pool.query(` + UPDATE achievements SET + name = COALESCE($2, name), + name_en = COALESCE($3, name_en), + description = COALESCE($4, description), + description_en = COALESCE($5, description_en), + category = COALESCE($6, category), + condition_type = COALESCE($7, condition_type), + condition_value = COALESCE($8, condition_value), + icon = COALESCE($9, icon), + points = COALESCE($10, points), + is_active = COALESCE($11, is_active), + can_be_earned_multiple_times = COALESCE($12, can_be_earned_multiple_times) + WHERE id = $1 + RETURNING * + `, [ + id, name, name_en, description, description_en, category, + condition_type, condition_value, icon, points, is_active, + can_be_earned_multiple_times + ]); + + if (result.rows.length === 0) { + return res.status(404).json({ + success: false, + message: 'Achievement nicht gefunden' + }); + } + + res.json({ + success: true, + message: 'Achievement erfolgreich aktualisiert', + data: result.rows[0] + }); + } catch (error) { + console.error('Error updating achievement:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Aktualisieren des Achievements' + }); + } +}); + +/** + * @swagger + * /api/admin/achievements/{id}: + * delete: + * summary: Achievement löschen + * description: Löscht ein Achievement (deaktiviert es nur) + * tags: [Admin, Achievements] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: ID des Achievements + * responses: + * 200: + * description: Achievement erfolgreich deaktiviert + * 404: + * description: Achievement nicht gefunden + * 500: + * description: Server-Fehler + */ +// Delete (deactivate) achievement +router.delete('/v1/admin/achievements/:id', requireAdminAuth, async (req, res) => { + try { + const { id } = req.params; + + const result = await pool.query(` + UPDATE achievements SET is_active = false + WHERE id = $1 + RETURNING * + `, [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ + success: false, + message: 'Achievement nicht gefunden' + }); + } + + res.json({ + success: true, + message: 'Achievement erfolgreich deaktiviert', + data: result.rows[0] + }); + } catch (error) { + console.error('Error deactivating achievement:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Deaktivieren des Achievements' + }); + } +}); + +/** + * @swagger + * /api/admin/achievements/players: + * get: + * summary: Alle Spieler mit ihren Achievements abrufen + * description: Ruft alle Spieler mit ihren Achievement-Statistiken ab + * tags: [Admin, Achievements] + * responses: + * 200: + * description: Liste aller Spieler mit Achievement-Statistiken + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * format: uuid + * firstname: + * type: string + * lastname: + * type: string + * total_achievements: + * type: integer + * completed_achievements: + * type: integer + * total_points: + * type: integer + * completion_percentage: + * type: number + * 500: + * description: Server-Fehler + */ +// Get all players with achievement statistics +router.get('/v1/admin/achievements/players', requireAdminAuth, async (req, res) => { + try { + const result = await pool.query(` + SELECT + p.id, + p.firstname, + p.lastname, + COUNT(DISTINCT a.id) as total_achievements, + COUNT(DISTINCT CASE WHEN pa.is_completed = true THEN pa.achievement_id END) as completed_achievements, + COALESCE(SUM(CASE WHEN pa.is_completed = true THEN a.points ELSE 0 END), 0) as total_points, + CASE + WHEN COUNT(DISTINCT a.id) > 0 THEN + ROUND((COUNT(DISTINCT CASE WHEN pa.is_completed = true THEN pa.achievement_id END)::numeric / COUNT(DISTINCT a.id)) * 100, 2) + ELSE 0 + END as completion_percentage + FROM players p + CROSS JOIN achievements a + LEFT JOIN player_achievements pa ON a.id = pa.achievement_id AND pa.player_id = p.id + WHERE a.is_active = true + GROUP BY p.id, p.firstname, p.lastname + ORDER BY total_points DESC, completion_percentage DESC, p.lastname, p.firstname + `); + + res.json({ + success: true, + data: result.rows + }); + } catch (error) { + console.error('Error fetching players with achievements:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Laden der Spieler-Achievements' + }); + } +}); + +/** + * @swagger + * /api/admin/achievements/players/{playerId}: + * get: + * summary: Achievements eines bestimmten Spielers abrufen + * description: Ruft alle Achievements für einen bestimmten Spieler ab + * tags: [Admin, Achievements] + * parameters: + * - in: path + * name: playerId + * required: true + * schema: + * type: string + * format: uuid + * description: ID des Spielers + * responses: + * 200: + * description: Achievements des Spielers + * 404: + * description: Spieler nicht gefunden + * 500: + * description: Server-Fehler + */ +// Get specific player achievements +router.get('/v1/admin/achievements/players/:playerId', requireAdminAuth, async (req, res) => { + try { + const { playerId } = req.params; + + // Check if player exists + const playerCheck = await pool.query('SELECT id, firstname, lastname FROM players WHERE id = $1', [playerId]); + if (playerCheck.rows.length === 0) { + return res.status(404).json({ + success: false, + message: 'Spieler nicht gefunden' + }); + } + + const result = await pool.query(` + SELECT + a.id, + a.name, + a.name_en, + a.description, + a.description_en, + a.category, + a.condition_type, + a.condition_value, + a.icon, + a.points, + a.is_active, + a.can_be_earned_multiple_times, + COALESCE(MAX(pa.progress), 0) as progress, + COALESCE(COUNT(pa.id) > 0, false) as is_completed, + MAX(pa.earned_at) as earned_at, + COUNT(pa.id) as completion_count + FROM achievements a + LEFT JOIN player_achievements pa ON a.id = pa.achievement_id AND pa.player_id = $1 AND pa.is_completed = true + WHERE a.is_active = true + GROUP BY a.id, a.name, a.name_en, a.description, a.description_en, a.category, + a.condition_type, a.condition_value, a.icon, a.points, a.is_active, a.can_be_earned_multiple_times + ORDER BY + is_completed DESC, + a.category, + a.points DESC + `, [playerId]); + + res.json({ + success: true, + player: playerCheck.rows[0], + data: result.rows + }); + } catch (error) { + console.error('Error fetching player achievements:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Laden der Spieler-Achievements' + }); + } +}); + +/** + * @swagger + * /api/admin/achievements/players/{playerId}/award: + * post: + * summary: Achievement an Spieler vergeben + * description: Vergibt ein Achievement manuell an einen Spieler + * tags: [Admin, Achievements] + * parameters: + * - in: path + * name: playerId + * required: true + * schema: + * type: string + * format: uuid + * description: ID des Spielers + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - achievement_id + * properties: + * achievement_id: + * type: string + * format: uuid + * description: ID des Achievements + * progress: + * type: integer + * description: Fortschritt des Achievements + * default: 1 + * responses: + * 201: + * description: Achievement erfolgreich vergeben + * 400: + * description: Ungültige Eingabedaten + * 404: + * description: Spieler oder Achievement nicht gefunden + * 500: + * description: Server-Fehler + */ +// Award achievement to player +router.post('/v1/admin/achievements/players/:playerId/award', requireAdminAuth, async (req, res) => { + try { + const { playerId } = req.params; + const { achievement_id, progress = 1 } = req.body; + + if (!achievement_id) { + return res.status(400).json({ + success: false, + message: 'Achievement-ID ist erforderlich' + }); + } + + // Check if player exists + const playerCheck = await pool.query('SELECT id, firstname, lastname FROM players WHERE id = $1', [playerId]); + if (playerCheck.rows.length === 0) { + return res.status(404).json({ + success: false, + message: 'Spieler nicht gefunden' + }); + } + + // Check if achievement exists + const achievementCheck = await pool.query('SELECT id, name FROM achievements WHERE id = $1', [achievement_id]); + if (achievementCheck.rows.length === 0) { + return res.status(404).json({ + success: false, + message: 'Achievement nicht gefunden' + }); + } + + // Award the achievement + const result = await pool.query(` + INSERT INTO player_achievements (player_id, achievement_id, progress, is_completed, earned_at) + VALUES ($1, $2, $3, true, NOW()) + RETURNING * + `, [playerId, achievement_id, progress]); + + res.status(201).json({ + success: true, + message: `Achievement "${achievementCheck.rows[0].name}" erfolgreich an ${playerCheck.rows[0].firstname} ${playerCheck.rows[0].lastname} vergeben`, + data: result.rows[0] + }); + } catch (error) { + console.error('Error awarding achievement:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Vergeben des Achievements' + }); + } +}); + +/** + * @swagger + * /api/admin/achievements/players/{playerId}/revoke: + * delete: + * summary: Achievement von Spieler entfernen + * description: Entfernt ein Achievement von einem Spieler + * tags: [Admin, Achievements] + * parameters: + * - in: path + * name: playerId + * required: true + * schema: + * type: string + * format: uuid + * description: ID des Spielers + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - achievement_id + * properties: + * achievement_id: + * type: string + * format: uuid + * description: ID des Achievements + * responses: + * 200: + * description: Achievement erfolgreich entfernt + * 404: + * description: Spieler oder Achievement nicht gefunden + * 500: + * description: Server-Fehler + */ +// Revoke achievement from player +router.delete('/v1/admin/achievements/players/:playerId/revoke', requireAdminAuth, async (req, res) => { + try { + const { playerId } = req.params; + const { achievement_id } = req.body; + + if (!achievement_id) { + return res.status(400).json({ + success: false, + message: 'Achievement-ID ist erforderlich' + }); + } + + // Check if player exists + const playerCheck = await pool.query('SELECT id, firstname, lastname FROM players WHERE id = $1', [playerId]); + if (playerCheck.rows.length === 0) { + return res.status(404).json({ + success: false, + message: 'Spieler nicht gefunden' + }); + } + + // Get achievement name for response + const achievementCheck = await pool.query('SELECT id, name FROM achievements WHERE id = $1', [achievement_id]); + if (achievementCheck.rows.length === 0) { + return res.status(404).json({ + success: false, + message: 'Achievement nicht gefunden' + }); + } + + // Remove the achievement + const result = await pool.query(` + DELETE FROM player_achievements + WHERE player_id = $1 AND achievement_id = $2 + RETURNING * + `, [playerId, achievement_id]); + + if (result.rows.length === 0) { + return res.status(404).json({ + success: false, + message: 'Spieler hat dieses Achievement nicht' + }); + } + + res.json({ + success: true, + message: `Achievement "${achievementCheck.rows[0].name}" erfolgreich von ${playerCheck.rows[0].firstname} ${playerCheck.rows[0].lastname} entfernt`, + data: result.rows[0] + }); + } catch (error) { + console.error('Error revoking achievement:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Entfernen des Achievements' + }); + } +}); + module.exports = { router, requireApiKey }; diff --git a/test-achievements.js b/test-achievements.js new file mode 100644 index 0000000..1838d6d --- /dev/null +++ b/test-achievements.js @@ -0,0 +1,46 @@ +const AchievementSystem = require('./lib/achievementSystem'); + +async function testAchievements() { + console.log('=== Testing Achievement System ==='); + + const achievementSystem = new AchievementSystem(); + const playerId = '08476bfc-5f48-486c-9f0b-90b81e5ccd8d'; + + try { + // Test 1: Load achievements + console.log('\n1. Loading achievements...'); + await achievementSystem.loadAchievements(); + console.log(`✅ Loaded ${achievementSystem.achievements.size} achievements`); + + // Test 2: Load player achievements + console.log('\n2. Loading player achievements...'); + await achievementSystem.loadPlayerAchievements(playerId); + const playerAchievements = achievementSystem.playerAchievements.get(playerId); + console.log(`✅ Player has ${playerAchievements ? playerAchievements.size : 0} achievements`); + + // Test 3: Check first time achievement + console.log('\n3. Checking first time achievement...'); + const firstTimeAchievement = Array.from(achievementSystem.achievements.values()) + .find(a => a.category === 'consistency' && a.condition_type === 'first_time'); + console.log('First time achievement:', firstTimeAchievement ? firstTimeAchievement.name : 'NOT FOUND'); + + // Test 4: Check September achievement + console.log('\n4. Checking September achievement...'); + const septemberAchievement = Array.from(achievementSystem.achievements.values()) + .find(a => a.category === 'monthly' && a.condition_type === 'september'); + console.log('September achievement:', septemberAchievement ? septemberAchievement.name : 'NOT FOUND'); + + // Test 5: Check immediate achievements + console.log('\n5. Running immediate achievement check...'); + const newAchievements = await achievementSystem.checkImmediateAchievements(playerId); + console.log(`✅ Found ${newAchievements.length} new achievements`); + newAchievements.forEach(achievement => { + console.log(` 🏆 ${achievement.name} (+${achievement.points} points)`); + }); + + } catch (error) { + console.error('❌ Error:', error); + } +} + +testAchievements();