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 @@ + +
Verwalte Achievements und Spieler-Achievements
+ +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.
+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.
+| ID | Name | Latitude | Longitude | Erstellt | Aktionen | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ID | Name | Latitude | Longitude | Mindestzeit (s) | Erstellt | Aktionen | |||||||||||||||||||||||||||
| ${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 = `
+
+
+
+
+
+
+ `;
+
+ 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 = `
+
+
+
+
+
+ `;
+
+ 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 = `
+
+
+ `;
+
+ 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 = '
+
+
+ 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 += `
+
+
+
+ `;
+ });
+
+ html += `
+
+
+ ${achievement.name}
+ ${statusIcon}
+
+
+
+ ${achievement.description} + +
+ ${!achievement.is_completed ?
+ `` :
+ ``
+ }
+
+ Keine Standort-Daten verfügbar '; + return; + } + + const locationHTML = locations.map(location => ` +
+ ${location.name}
+
+ `).join('');
+
+ container.innerHTML = locationHTML;
+}
+
+function displayPersonalRecords(records) {
+ const container = document.getElementById('personalRecords');
+
+ if (!records || records.length === 0) {
+ container.innerHTML = '
+ ${formatTime(location.bestTime)}
+ (${location.runs} Läufe)
+
+ Keine Bestzeiten verfügbar '; + return; + } + + const recordsHTML = records.map((record, index) => ` +
+
+ `).join('');
+
+ container.innerHTML = recordsHTML;
+}
+
+function displayRankingStats(rankings) {
+ const container = document.getElementById('rankingStats');
+
+ if (!rankings || rankings.length === 0) {
+ container.innerHTML = '
+ #${index + 1}
+ ${formatTime(record.time)}
+
+ ${record.location}
+ Keine Ranglisten-Daten verfügbar '; + return; + } + + const rankingsHTML = rankings.map(ranking => ` +
+ ${ranking.category}
+
+ `).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 = '
+ #${ranking.position}
+ von ${ranking.total}
+
+ 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(); |