diff --git a/lib/achievementSystem.js b/lib/achievementSystem.js index b255509..9ba747f 100644 --- a/lib/achievementSystem.js +++ b/lib/achievementSystem.js @@ -89,7 +89,17 @@ class AchievementSystem { }); }); - console.log(`📋 ${result.rows.length} Achievements für Spieler ${playerId} geladen`); + // Count unique achievements and duplicates + const uniqueAchievements = new Set(result.rows.map(row => row.achievement_id)); + const totalCount = result.rows.length; + const uniqueCount = uniqueAchievements.size; + const duplicateCount = totalCount - uniqueCount; + + if (duplicateCount > 0) { + console.log(`📋 ${totalCount} Achievements für Spieler ${playerId} geladen (${uniqueCount} eindeutige, ${duplicateCount} doppelt)`); + } else { + console.log(`📋 ${totalCount} Achievements für Spieler ${playerId} geladen`); + } return true; } catch (error) { console.error(`❌ Fehler beim Laden der Spieler-Achievements für ${playerId}:`, error); @@ -535,106 +545,138 @@ class AchievementSystem { } /** - * Prüft Tageskönig Achievement + * Prüft Tageskönig Achievement pro Standort */ async checkDailyBest(playerId, currentDate, newAchievements) { const achievement = Array.from(this.achievements.values()) - .find(a => a.category === 'best_time' && a.condition_type === 'daily_best'); + .find(a => a.category === 'time' && a.condition_type === 'best_time_daily_location'); 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(` - SELECT MIN(recorded_time) as best_time + // Hole alle Standorte, an denen der Spieler heute gespielt hat + const locationsResult = await pool.query(` + SELECT DISTINCT t.location_id, l.name as location_name FROM times t + INNER JOIN locations l ON t.location_id = l.id WHERE t.player_id = $1 AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $2 `, [playerId, currentDate]); - // Hole beste Zeit des Tages - const dailyResult = await pool.query(` - SELECT MIN(recorded_time) as best_time - FROM times t - WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $1 - `, [currentDate]); + for (const location of locationsResult.rows) { + // Prüfe ob das Achievement heute bereits für diesen Standort 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 pa.location_id = $3 + AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE + `, [playerId, achievement.id, location.location_id]); + + if (parseInt(alreadyEarnedToday.rows[0].count) > 0) continue; - const playerBest = playerResult.rows[0].best_time; - const dailyBest = dailyResult.rows[0].best_time; + // Hole beste Zeit des Spielers heute an diesem Standort + const playerResult = await pool.query(` + SELECT MIN(recorded_time) as best_time + FROM times t + WHERE t.player_id = $1 + AND t.location_id = $2 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $3 + `, [playerId, location.location_id, currentDate]); - if (playerBest && dailyBest && playerBest === dailyBest) { - await this.awardAchievement(playerId, achievement, 1, newAchievements); + // Hole beste Zeit des Tages an diesem Standort + const dailyResult = await pool.query(` + SELECT MIN(recorded_time) as best_time + FROM times t + WHERE t.location_id = $1 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $2 + `, [location.location_id, currentDate]); + + const playerBest = playerResult.rows[0].best_time; + const dailyBest = dailyResult.rows[0].best_time; + + if (playerBest && dailyBest && playerBest === dailyBest) { + await this.awardAchievement(playerId, achievement, 1, newAchievements, location.location_id); + console.log(`🏆 Tageskönig Achievement vergeben für Standort: ${location.location_name}`); + } } } /** - * Prüft Wochenchampion Achievement + * Prüft Wochenchampion Achievement pro Standort */ async checkWeeklyBest(playerId, currentDate, newAchievements) { const achievement = Array.from(this.achievements.values()) - .find(a => a.category === 'best_time' && a.condition_type === 'weekly_best'); + .find(a => a.category === 'time' && a.condition_type === 'best_time_weekly_location'); if (!achievement) return; - // Prüfe ob das Achievement diese Woche bereits vergeben wurde + // Berechne Woche 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(` - SELECT MIN(recorded_time) as best_time + // Hole alle Standorte, an denen der Spieler diese Woche gespielt hat + const locationsResult = await pool.query(` + SELECT DISTINCT t.location_id, l.name as location_name FROM times t + INNER JOIN locations l ON t.location_id = l.id WHERE t.player_id = $1 AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $2 AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $3 `, [playerId, weekStartStr, currentDate]); - // Hole beste Zeit der Woche - const weeklyResult = await pool.query(` - SELECT MIN(recorded_time) as best_time - FROM times t - WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1 - AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2 - `, [weekStartStr, currentDate]); + for (const location of locationsResult.rows) { + // Prüfe ob das Achievement diese Woche bereits für diesen Standort vergeben wurde + const alreadyEarnedThisWeek = await pool.query(` + SELECT COUNT(*) as count + FROM player_achievements pa + WHERE pa.player_id = $1 + AND pa.achievement_id = $2 + AND pa.location_id = $3 + AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') >= $4 + AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') <= $5 + `, [playerId, achievement.id, location.location_id, weekStartStr, currentDate]); + + if (parseInt(alreadyEarnedThisWeek.rows[0].count) > 0) continue; - const playerBest = playerResult.rows[0].best_time; - const weeklyBest = weeklyResult.rows[0].best_time; + // Hole beste Zeit des Spielers diese Woche an diesem Standort + const playerResult = await pool.query(` + SELECT MIN(recorded_time) as best_time + FROM times t + WHERE t.player_id = $1 + AND t.location_id = $2 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $3 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $4 + `, [playerId, location.location_id, weekStartStr, currentDate]); - if (playerBest && weeklyBest && playerBest === weeklyBest) { - await this.awardAchievement(playerId, achievement, 1, newAchievements); + // Hole beste Zeit der Woche an diesem Standort + const weeklyResult = await pool.query(` + SELECT MIN(recorded_time) as best_time + FROM times t + WHERE t.location_id = $1 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $2 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $3 + `, [location.location_id, weekStartStr, currentDate]); + + const playerBest = playerResult.rows[0].best_time; + const weeklyBest = weeklyResult.rows[0].best_time; + + if (playerBest && weeklyBest && playerBest === weeklyBest) { + await this.awardAchievement(playerId, achievement, 1, newAchievements, location.location_id); + console.log(`🏆 Wochenchampion Achievement vergeben für Standort: ${location.location_name}`); + } } } /** - * Prüft Monatsmeister Achievement + * Prüft Monatsmeister Achievement pro Standort */ async checkMonthlyBest(playerId, currentDate, newAchievements) { const achievement = Array.from(this.achievements.values()) - .find(a => a.category === 'best_time' && a.condition_type === 'monthly_best'); + .find(a => a.category === 'time' && a.condition_type === 'best_time_monthly_location'); if (!achievement) return; @@ -642,41 +684,57 @@ class AchievementSystem { 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(` - SELECT MIN(recorded_time) as best_time + // Hole alle Standorte, an denen der Spieler diesen Monat gespielt hat + const locationsResult = await pool.query(` + SELECT DISTINCT t.location_id, l.name as location_name FROM times t + INNER JOIN locations l ON t.location_id = l.id WHERE t.player_id = $1 AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $2 AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $3 `, [playerId, monthStartStr, currentDate]); - // Hole beste Zeit des Monats - const monthlyResult = await pool.query(` - SELECT MIN(recorded_time) as best_time - FROM times t - WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1 - AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2 - `, [monthStartStr, currentDate]); + for (const location of locationsResult.rows) { + // Prüfe ob das Achievement diesen Monat bereits für diesen Standort 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 pa.location_id = $3 + AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') >= $4 + AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') <= $5 + `, [playerId, achievement.id, location.location_id, monthStartStr, currentDate]); + + if (parseInt(alreadyEarnedThisMonth.rows[0].count) > 0) continue; - const playerBest = playerResult.rows[0].best_time; - const monthlyBest = monthlyResult.rows[0].best_time; + // Hole beste Zeit des Spielers diesen Monat an diesem Standort + const playerResult = await pool.query(` + SELECT MIN(recorded_time) as best_time + FROM times t + WHERE t.player_id = $1 + AND t.location_id = $2 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $3 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $4 + `, [playerId, location.location_id, monthStartStr, currentDate]); - if (playerBest && monthlyBest && playerBest === monthlyBest) { - await this.awardAchievement(playerId, achievement, 1, newAchievements); + // Hole beste Zeit des Monats an diesem Standort + const monthlyResult = await pool.query(` + SELECT MIN(recorded_time) as best_time + FROM times t + WHERE t.location_id = $1 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $2 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $3 + `, [location.location_id, monthStartStr, currentDate]); + + const playerBest = playerResult.rows[0].best_time; + const monthlyBest = monthlyResult.rows[0].best_time; + + if (playerBest && monthlyBest && playerBest === monthlyBest) { + await this.awardAchievement(playerId, achievement, 1, newAchievements, location.location_id); + console.log(`🏆 Monatsmeister Achievement vergeben für Standort: ${location.location_name}`); + } } } @@ -684,12 +742,12 @@ class AchievementSystem { * Vergibt ein Achievement an einen Spieler * Erstellt immer einen neuen Eintrag (keine Updates mehr) */ - async awardAchievement(playerId, achievement, progress, newAchievements) { + async awardAchievement(playerId, achievement, progress, newAchievements, locationId = null) { try { await pool.query(` - INSERT INTO player_achievements (player_id, achievement_id, progress, is_completed, earned_at) - VALUES ($1, $2, $3, true, NOW()) - `, [playerId, achievement.id, progress]); + INSERT INTO player_achievements (player_id, achievement_id, progress, is_completed, earned_at, location_id) + VALUES ($1, $2, $3, true, NOW(), $4) + `, [playerId, achievement.id, progress, locationId]); newAchievements.push({ id: achievement.id, @@ -697,10 +755,12 @@ class AchievementSystem { description: achievement.description, icon: achievement.icon, points: achievement.points, - progress: progress + progress: progress, + locationId: locationId }); - console.log(`🏆 Achievement vergeben: ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)`); + const locationText = locationId ? ` (Standort: ${locationId})` : ''; + console.log(`🏆 Achievement vergeben: ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)${locationText}`); } catch (error) { console.error(`❌ Fehler beim Vergeben des Achievements ${achievement.name}:`, error); } diff --git a/public/css/dashboard.css b/public/css/dashboard.css index 56b0f6b..1d920b8 100644 --- a/public/css/dashboard.css +++ b/public/css/dashboard.css @@ -137,6 +137,52 @@ body { box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4); } +/* Push notification button */ +.btn-push { + background: #10b981; + color: white; + position: relative; +} + +.btn-push:hover { + background: #059669; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); +} + +.btn-push.active { + background: #ef4444; +} + +.btn-push.active:hover { + background: #dc2626; + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4); +} + +.btn-push::after { + content: ''; + position: absolute; + top: -2px; + right: -2px; + width: 8px; + height: 8px; + background: #fbbf24; + border-radius: 50%; + opacity: 0; + transition: opacity 0.3s ease; +} + +.btn-push.active::after { + opacity: 1; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.2); opacity: 0.7; } + 100% { transform: scale(1); opacity: 1; } +} + /* Logout button */ .btn-logout { background: #dc2626; diff --git a/public/dashboard.html b/public/dashboard.html index 4703ca7..aafbe16 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -29,42 +29,259 @@ }); } - // Request notification permission on page load - if ('Notification' in window) { - if (Notification.permission === 'default') { - Notification.requestPermission().then(function(permission) { - if (permission === 'granted') { - console.log('✅ Notification permission granted'); - // Subscribe to push notifications - subscribeToPush(); - } else { - console.log('❌ Notification permission denied'); - } - }); - } - } + // Don't automatically request notification permission + // User must click the button to enable push notifications + // Convert VAPID key from base64url to Uint8Array + function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; + } + + // Convert ArrayBuffer to Base64 string + function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } + + // Push notification state + let pushSubscription = null; + let pushEnabled = false; + // Subscribe to push notifications async function subscribeToPush() { try { + console.log('🔔 Starting push subscription...'); + const registration = await navigator.serviceWorker.ready; + const vapidPublicKey = 'BJmNVx0C3XeVxeKGTP9c-Z4HcuZNmdk6QdiLocZgCmb-miCS0ESFO3W2TvJlRhhNAShV63pWA5p36BTVSetyTds'; + const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey); + const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: 'BJmNVx0C3XeVxeKGTP9c-Z4HcuZNmdk6QdiLocZgCmb-miCS0ESFO3W2TvJlRhhNAShV63pWA5p36BTVSetyTds' + applicationServerKey: applicationServerKey }); - // Send subscription to server - await fetch('/api/v1/public/subscribe', { + pushSubscription = subscription; + + // Generate or get player ID + let playerId = window.currentPlayerId; + if (!playerId) { + // Generate a UUID for the player + playerId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + console.log(`📱 Generated player ID: ${playerId}`); + } else { + console.log(`📱 Using existing player ID: ${playerId}`); + } + + // Convert ArrayBuffer keys to Base64 strings + const p256dhKey = subscription.getKey('p256dh'); + const authKey = subscription.getKey('auth'); + + // Convert ArrayBuffer to Base64 URL-safe string + const p256dhString = arrayBufferToBase64(p256dhKey); + const authString = arrayBufferToBase64(authKey); + + console.log('📱 Converted keys to Base64 strings'); + console.log('📱 p256dh length:', p256dhString.length); + console.log('📱 auth length:', authString.length); + + // Send subscription to server with player ID + const response = await fetch('/api/v1/public/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(subscription) + body: JSON.stringify({ + endpoint: subscription.endpoint, + keys: { + p256dh: p256dhString, + auth: authString + }, + playerId: playerId + }) }); - console.log('✅ Push subscription successful'); + const result = await response.json(); + if (result.success) { + pushEnabled = true; + updatePushButton(); + console.log('✅ Push subscription successful'); + + // Store player ID for notifications + if (result.playerId) { + localStorage.setItem('pushPlayerId', result.playerId); + } + } else { + throw new Error(result.message); + } } catch (error) { console.error('❌ Push subscription failed:', error); + pushEnabled = false; + updatePushButton(); + } + } + + // Unsubscribe from push notifications + async function unsubscribeFromPush() { + try { + console.log('🔕 Unsubscribing from push notifications...'); + + // Get player ID from localStorage + const playerId = localStorage.getItem('pushPlayerId'); + + if (pushSubscription) { + await pushSubscription.unsubscribe(); + pushSubscription = null; + console.log('✅ Local push subscription removed'); + } + + // Notify server to remove from database + if (playerId) { + try { + const response = await fetch('/api/v1/public/unsubscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ playerId: playerId }) + }); + + const result = await response.json(); + if (result.success) { + console.log('✅ Server notified - subscription removed from database'); + } else { + console.warn('⚠️ Server notification failed:', result.message); + } + } catch (error) { + console.warn('⚠️ Failed to notify server:', error); + } + } + + // Clear stored player ID + localStorage.removeItem('pushPlayerId'); + + pushEnabled = false; + updatePushButton(); + console.log('🔕 Push notifications disabled'); + } catch (error) { + console.error('❌ Push unsubscribe failed:', error); + pushEnabled = false; + updatePushButton(); + } + } + + // Toggle push notifications + async function togglePushNotifications() { + if (pushEnabled) { + await unsubscribeFromPush(); + } else { + // Check notification permission first + if (Notification.permission === 'denied') { + alert('Push-Benachrichtigungen sind blockiert. Bitte erlaube sie in den Browser-Einstellungen.'); + return; + } + + if (Notification.permission === 'default') { + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + alert('Push-Benachrichtigungen wurden nicht erlaubt.'); + return; + } + } + + await subscribeToPush(); + } + } + + // Update push button appearance + function updatePushButton() { + const button = document.getElementById('pushButton'); + if (!button) { + console.log('❌ Push button not found in DOM'); + return; + } + + console.log(`🔔 Updating push button - Status: ${pushEnabled ? 'ENABLED' : 'DISABLED'}`); + + if (pushEnabled) { + button.classList.add('active'); + button.setAttribute('data-de', '🔕 Push deaktivieren'); + button.setAttribute('data-en', '🔕 Disable Push'); + button.textContent = '🔕 Push deaktivieren'; + console.log('✅ Button updated to: Push deaktivieren (RED)'); + } else { + button.classList.remove('active'); + button.setAttribute('data-de', '🔔 Push aktivieren'); + button.setAttribute('data-en', '🔔 Enable Push'); + button.textContent = '🔔 Push aktivieren'; + console.log('✅ Button updated to: Push aktivieren (GREEN)'); + } + } + + // Check existing push subscription on page load + async function checkPushStatus() { + try { + console.log('🔍 Checking push status...'); + + if (!('serviceWorker' in navigator)) { + console.log('❌ Service Worker not supported'); + pushEnabled = false; + updatePushButton(); + return; + } + + if (!('PushManager' in window)) { + console.log('❌ Push Manager not supported'); + pushEnabled = false; + updatePushButton(); + return; + } + + const registration = await navigator.serviceWorker.ready; + console.log('✅ Service Worker ready'); + + const subscription = await registration.pushManager.getSubscription(); + console.log('📱 Current subscription:', subscription ? 'EXISTS' : 'NONE'); + + if (subscription) { + pushSubscription = subscription; + pushEnabled = true; + updatePushButton(); + console.log('✅ Existing push subscription found and activated'); + + // Also check if we have a stored player ID + const storedPlayerId = localStorage.getItem('pushPlayerId'); + if (storedPlayerId) { + console.log(`📱 Push subscription linked to player: ${storedPlayerId}`); + } + } else { + pushEnabled = false; + updatePushButton(); + console.log('ℹ️ No existing push subscription found'); + } + } catch (error) { + console.error('❌ Error checking push status:', error); + pushEnabled = false; + updatePushButton(); } } @@ -84,6 +301,7 @@