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 @@
U
user@example.com + Back to Times diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 44597c4..a4bd23d 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -324,6 +324,9 @@ document.addEventListener('DOMContentLoaded', function () { loadLanguagePreference(); changeLanguage(); // Apply saved language initDashboard(); + + // Push notifications are now handled manually via the button + // No automatic subscription on page load }); // Modal functions @@ -1739,6 +1742,25 @@ function initializeAchievements(playerId) { loadPlayerAchievements(); } +// 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; +} + +// Push notifications are now handled in dashboard.html +// This function has been moved to the HTML file for better integration + // Web Notification Functions function showWebNotification(title, message, icon = '🏆') { if ('Notification' in window && Notification.permission === 'granted') { @@ -1763,9 +1785,23 @@ function showWebNotification(title, message, icon = '🏆') { } } +// Track which notifications have been sent today +let notificationsSentToday = { + daily: false, + weekly: false, + monthly: false +}; + // Check for best time achievements and show notifications async function checkBestTimeNotifications() { try { + // Check if push notifications are enabled + const pushPlayerId = localStorage.getItem('pushPlayerId'); + if (!pushPlayerId) { + console.log('🔕 Push notifications disabled, skipping best time check'); + return; + } + const response = await fetch('/api/v1/public/best-times'); const result = await response.json(); @@ -1774,28 +1810,95 @@ async function checkBestTimeNotifications() { // Check if current player has best times if (currentPlayerId) { - if (daily && daily.player_id === currentPlayerId) { - const title = currentLanguage === 'de' ? '🏆 Tageskönig!' : '🏆 Daily King!'; - const message = currentLanguage === 'de' ? - `Glückwunsch! Du hast die beste Zeit des Tages mit ${daily.best_time} erreicht!` : - `Congratulations! You achieved the best time of the day with ${daily.best_time}!`; - showWebNotification(title, message, '👑'); + const now = new Date(); + const isEvening = now.getHours() >= 19; + + // Check daily best time (only in the evening, only once per day) + if (daily && daily.player_id === currentPlayerId && isEvening) { + // Check if notification was already sent today + const dailyCheck = await fetch(`/api/v1/public/notification-sent/${currentPlayerId}/daily_best`); + const dailyResult = await dailyCheck.json(); + + if (!dailyResult.wasSent) { + const title = currentLanguage === 'de' ? '🏆 Tageskönig!' : '🏆 Daily King!'; + const message = currentLanguage === 'de' ? + `Glückwunsch! Du hast die beste Zeit des Tages mit ${daily.best_time} erreicht!` : + `Congratulations! You achieved the best time of the day with ${daily.best_time}!`; + showWebNotification(title, message, '👑'); + + // Mark as sent in database + await fetch('/api/v1/public/notification-sent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playerId: currentPlayerId, + notificationType: 'daily_best' + }) + }); + console.log('🏆 Daily best notification sent'); + } } + // Check weekly best time (only on Sunday evening, only once per week) if (weekly && weekly.player_id === currentPlayerId) { - const title = currentLanguage === 'de' ? '🏆 Wochenchampion!' : '🏆 Weekly Champion!'; - const message = currentLanguage === 'de' ? - `Fantastisch! Du bist der Wochenchampion mit ${weekly.best_time}!` : - `Fantastic! You are the weekly champion with ${weekly.best_time}!`; - showWebNotification(title, message, '🏆'); + const isSunday = now.getDay() === 0; // 0 = Sunday + + if (isSunday && isEvening) { + // Check if notification was already sent this week + const weeklyCheck = await fetch(`/api/v1/public/notification-sent/${currentPlayerId}/weekly_best`); + const weeklyResult = await weeklyCheck.json(); + + if (!weeklyResult.wasSent) { + const title = currentLanguage === 'de' ? '🏆 Wochenchampion!' : '🏆 Weekly Champion!'; + const message = currentLanguage === 'de' ? + `Fantastisch! Du bist der Wochenchampion mit ${weekly.best_time}!` : + `Fantastic! You are the weekly champion with ${weekly.best_time}!`; + showWebNotification(title, message, '🏆'); + + // Mark as sent in database + await fetch('/api/v1/public/notification-sent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playerId: currentPlayerId, + notificationType: 'weekly_best' + }) + }); + console.log('🏆 Weekly best notification sent'); + } + } } + // Check monthly best time (only on last evening of month at 19:00, only once per month) if (monthly && monthly.player_id === currentPlayerId) { - const title = currentLanguage === 'de' ? '🏆 Monatsmeister!' : '🏆 Monthly Master!'; - const message = currentLanguage === 'de' ? - `Unglaublich! Du bist der Monatsmeister mit ${monthly.best_time}!` : - `Incredible! You are the monthly master with ${monthly.best_time}!`; - showWebNotification(title, message, '🥇'); + const lastDayOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0); + const isLastDayOfMonth = now.getDate() === lastDayOfMonth.getDate(); + + // Only show monthly notification on last day of month at 19:00 or later + if (isLastDayOfMonth && isEvening) { + // Check if notification was already sent this month + const monthlyCheck = await fetch(`/api/v1/public/notification-sent/${currentPlayerId}/monthly_best`); + const monthlyResult = await monthlyCheck.json(); + + if (!monthlyResult.wasSent) { + const title = currentLanguage === 'de' ? '🏆 Monatsmeister!' : '🏆 Monthly Master!'; + const message = currentLanguage === 'de' ? + `Unglaublich! Du bist der Monatsmeister mit ${monthly.best_time}!` : + `Incredible! You are the monthly master with ${monthly.best_time}!`; + showWebNotification(title, message, '🥇'); + + // Mark as sent in database + await fetch('/api/v1/public/notification-sent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playerId: currentPlayerId, + notificationType: 'monthly_best' + }) + }); + console.log('🏆 Monthly best notification sent'); + } + } } } } @@ -1809,6 +1912,13 @@ async function checkAchievementNotifications() { try { if (!currentPlayerId) return; + // Check if push notifications are enabled + const pushPlayerId = localStorage.getItem('pushPlayerId'); + if (!pushPlayerId) { + console.log('🔕 Push notifications disabled, skipping achievement check'); + return; + } + const response = await fetch(`/api/achievements/player/${currentPlayerId}?t=${Date.now()}`); const result = await response.json(); @@ -1821,14 +1931,33 @@ async function checkAchievementNotifications() { }); if (newAchievements.length > 0) { - newAchievements.forEach(achievement => { - const translatedAchievement = translateAchievement(achievement); - showWebNotification( - `🏆 ${translatedAchievement.name}`, - translatedAchievement.description, - achievement.icon || '🏆' - ); - }); + for (const achievement of newAchievements) { + // Check if notification was already sent for this achievement + const achievementCheck = await fetch(`/api/v1/public/notification-sent/${currentPlayerId}/achievement?achievementId=${achievement.achievement_id}&locationId=${achievement.location_id || ''}`); + const achievementResult = await achievementCheck.json(); + + if (!achievementResult.wasSent) { + const translatedAchievement = translateAchievement(achievement); + showWebNotification( + `🏆 ${translatedAchievement.name}`, + translatedAchievement.description, + achievement.icon || '🏆' + ); + + // Mark as sent in database + await fetch('/api/v1/public/notification-sent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playerId: currentPlayerId, + notificationType: 'achievement', + achievementId: achievement.achievement_id, + locationId: achievement.location_id || null + }) + }); + console.log(`🏆 Achievement notification sent: ${achievement.name}`); + } + } } } } catch (error) { @@ -1967,4 +2096,9 @@ document.addEventListener('DOMContentLoaded', function () { } }); } + + // Check push notification status on dashboard load + if (typeof checkPushStatus === 'function') { + checkPushStatus(); + } }); diff --git a/public/sw.js b/public/sw.js index b431606..cbf68d6 100644 --- a/public/sw.js +++ b/public/sw.js @@ -2,22 +2,65 @@ const CACHE_NAME = 'ninjacross-v1'; const urlsToCache = [ '/', - '/index.html', - '/css/leaderboard.css', - '/js/leaderboard.js', - '/pictures/favicon.ico' + '/test-push.html', + '/sw.js' ]; // Install event self.addEventListener('install', function(event) { + console.log('Service Worker installing...'); event.waitUntil( caches.open(CACHE_NAME) .then(function(cache) { - return cache.addAll(urlsToCache); + // Add files one by one to handle failures gracefully + return Promise.allSettled( + urlsToCache.map(url => + cache.add(url).catch(err => { + console.warn(`Failed to cache ${url}:`, err); + return null; // Continue with other files + }) + ) + ); + }) + .then(() => { + console.log('Service Worker installation completed'); + // Skip waiting to activate immediately + return self.skipWaiting(); + }) + .catch(err => { + console.error('Service Worker installation failed:', err); + // Still try to skip waiting + return self.skipWaiting(); }) ); }); +// Activate event +self.addEventListener('activate', function(event) { + console.log('Service Worker activating...'); + event.waitUntil( + caches.keys().then(function(cacheNames) { + return Promise.all( + cacheNames.map(function(cacheName) { + if (cacheName !== CACHE_NAME) { + return caches.delete(cacheName); + } + }) + ); + }).then(() => { + // Take control of all clients immediately + return self.clients.claim(); + }) + ); +}); + +// Listen for skip waiting message +self.addEventListener('message', function(event) { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + // Fetch event self.addEventListener('fetch', function(event) { event.respondWith( diff --git a/public/test-push.html b/public/test-push.html index 39b75bc..78dfdf1 100644 --- a/public/test-push.html +++ b/public/test-push.html @@ -105,6 +105,7 @@ +
@@ -120,6 +121,22 @@ diff --git a/routes/api.js b/routes/api.js index 6f30afc..b846907 100644 --- a/routes/api.js +++ b/routes/api.js @@ -2848,12 +2848,33 @@ router.get('/v1/public/best-times', async (req, res) => { // Subscribe to push notifications router.post('/v1/public/subscribe', async (req, res) => { try { - const { endpoint, keys } = req.body; + console.log('📱 Push subscription request received:', JSON.stringify(req.body, null, 2)); + + const { endpoint, keys, playerId: requestPlayerId } = req.body; const userId = req.session.userId || 'anonymous'; - // Generate a UUID for anonymous users or use existing UUID + // Validate required fields + if (!endpoint) { + console.error('❌ Missing endpoint in request'); + return res.status(400).json({ + success: false, + message: 'Endpoint ist erforderlich' + }); + } + + if (!keys || !keys.p256dh || !keys.auth) { + console.error('❌ Missing keys in request:', keys); + return res.status(400).json({ + success: false, + message: 'Push-Keys sind erforderlich' + }); + } + + // Use playerId from request if provided, otherwise generate one let playerId; - if (userId === 'anonymous') { + if (requestPlayerId) { + playerId = requestPlayerId; + } else if (userId === 'anonymous') { // Generate a random UUID for anonymous users const { v4: uuidv4 } = require('uuid'); playerId = uuidv4(); @@ -2861,6 +2882,8 @@ router.post('/v1/public/subscribe', async (req, res) => { playerId = userId; } + console.log(`📱 Processing subscription for player: ${playerId}`); + // Store subscription in database await pool.query(` INSERT INTO player_subscriptions (player_id, endpoint, p256dh, auth, created_at) @@ -2890,7 +2913,8 @@ router.post('/v1/public/subscribe', async (req, res) => { res.json({ success: true, - message: 'Push subscription erfolgreich gespeichert' + message: 'Push subscription erfolgreich gespeichert', + playerId: playerId }); } catch (error) { console.error('Error storing push subscription:', error); @@ -2902,6 +2926,42 @@ router.post('/v1/public/subscribe', async (req, res) => { } }); +// Unsubscribe from push notifications +router.post('/v1/public/unsubscribe', async (req, res) => { + try { + const { playerId } = req.body; + + if (!playerId) { + return res.status(400).json({ + success: false, + message: 'Player ID erforderlich' + }); + } + + // Remove from push service + pushService.unsubscribe(playerId); + + // Remove from database + await pool.query(` + DELETE FROM player_subscriptions + WHERE player_id = $1 + `, [playerId]); + + console.log(`User ${playerId} unsubscribed from push notifications`); + + res.json({ + success: true, + message: 'Push subscription erfolgreich entfernt' + }); + } catch (error) { + console.error('Error removing push subscription:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Entfernen der Push Subscription' + }); + } +}); + // Test push notification endpoint router.post('/v1/public/test-push', async (req, res) => { try { @@ -2960,6 +3020,63 @@ router.get('/v1/public/push-status', async (req, res) => { } }); +// Check if notification was already sent today +router.get('/v1/public/notification-sent/:playerId/:type', async (req, res) => { + try { + const { playerId, type } = req.params; + const { achievementId, locationId } = req.query; + + const today = new Date().toISOString().split('T')[0]; + + const result = await pool.query(` + SELECT COUNT(*) as count + FROM sent_notifications + WHERE player_id = $1 + AND notification_type = $2 + AND (achievement_id = $3 OR ($3 IS NULL AND achievement_id IS NULL)) + AND (location_id = $4 OR ($4 IS NULL AND location_id IS NULL)) + AND DATE(sent_at) = $5 + `, [playerId, type, achievementId || null, locationId || null, today]); + + const wasSent = parseInt(result.rows[0].count) > 0; + + res.json({ + success: true, + wasSent: wasSent + }); + } catch (error) { + console.error('Error checking notification status:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Prüfen des Notification-Status' + }); + } +}); + +// Mark notification as sent +router.post('/v1/public/notification-sent', async (req, res) => { + try { + const { playerId, notificationType, achievementId, locationId } = req.body; + + await pool.query(` + INSERT INTO sent_notifications (player_id, notification_type, achievement_id, location_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT DO NOTHING + `, [playerId, notificationType, achievementId || null, locationId || null]); + + res.json({ + success: true, + message: 'Notification als gesendet markiert' + }); + } catch (error) { + console.error('Error marking notification as sent:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Markieren der Notification' + }); + } +}); + // ==================== ANALYTICS HELPER FUNCTIONS ==================== async function getPerformanceTrends(playerId) {