/** * Dashboard Controller for StreetRacer Web Dashboard * Handles statistics, sessions list, and rankings */ class Dashboard { constructor() { this.currentTab = 'statistics'; this.sessions = []; this.selectedSession = null; this.sessionMap = null; this.activityChart = null; this.currentRankingType = 'speed'; this.currentRankingPeriod = 'monthly'; this.userTier = null; // null until loaded this.hasAdvancedStats = false; this.setupEventListeners(); } setupEventListeners() { // Tab navigation document.querySelectorAll('.nav-btn').forEach(btn => { btn.addEventListener('click', (e) => { const tab = e.currentTarget.dataset.tab; this.switchTab(tab); }); }); // Ranking category tabs document.querySelectorAll('.ranking-tab[data-ranking]').forEach(btn => { btn.addEventListener('click', (e) => { this.currentRankingType = e.currentTarget.dataset.ranking; this.loadRankings(this.currentRankingType); this.loadTeamRankings(this.currentRankingType); document.querySelectorAll('.ranking-tab[data-ranking]').forEach(t => t.classList.remove('active')); e.currentTarget.classList.add('active'); }); }); // Ranking period tabs document.querySelectorAll('.ranking-tab[data-period]').forEach(btn => { btn.addEventListener('click', (e) => { this.currentRankingPeriod = e.currentTarget.dataset.period; this.loadRankings(this.currentRankingType); this.loadTeamRankings(this.currentRankingType); document.querySelectorAll('.ranking-tab[data-period]').forEach(t => t.classList.remove('active')); e.currentTarget.classList.add('active'); }); }); // Close session detail on escape document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.selectedSession) { this.closeSessionDetail(); } }); } initialize() { console.log('Dashboard initializing...'); // Check if Mapbox is loaded if (typeof mapboxgl === 'undefined') { console.error('Mapbox GL JS not loaded!'); } else { console.log('Mapbox GL JS loaded, version:', mapboxgl.version); const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); if (!gl) { console.warn('WebGL not supported - maps may not work'); } else { console.log('WebGL supported'); } } // Load tier first, then load all data this.loadTier().then(() => { this.loadStatistics(); this.applyTierGating(); if (this.hasAdvancedStats) { this.loadSessions(); this.loadRankings(this.currentRankingType); this.loadTeamRankings(this.currentRankingType); } }); } async loadTier() { const user = auth.currentUser; if (!user) return; try { const subRef = database.ref(`subscriptions/${user.uid}`); const snapshot = await subRef.once('value'); const sub = snapshot.val(); if (sub && sub.tier) { this.userTier = sub.tier; } else { this.userTier = 'free'; } // Premium and VIP have advancedStats this.hasAdvancedStats = (this.userTier === 'premium' || this.userTier === 'vip'); console.log('User tier:', this.userTier, 'advancedStats:', this.hasAdvancedStats); // Update VIP label in header const vipLabel = document.querySelector('.vip-label'); if (vipLabel) { if (this.userTier === 'vip') { vipLabel.textContent = 'VIP'; vipLabel.style.display = ''; } else if (this.userTier === 'premium') { vipLabel.textContent = 'PREMIUM'; vipLabel.style.display = ''; } else if (this.userTier === 'standard') { vipLabel.textContent = 'STANDARD'; vipLabel.style.display = ''; } else { vipLabel.style.display = 'none'; } } } catch (error) { console.error('Error loading tier:', error); this.userTier = 'free'; this.hasAdvancedStats = false; } } applyTierGating() { // Rankings section const rankingsContent = document.getElementById('rankings-content'); const rankingsLock = document.getElementById('rankings-lock-overlay'); const rankingsLockIcon = document.getElementById('rankings-lock-icon'); // Sessions section const sessionsContent = document.getElementById('sessions-content'); const sessionsLock = document.getElementById('sessions-lock-overlay'); const sessionsLockIcon = document.getElementById('sessions-lock-icon'); if (this.hasAdvancedStats) { // Unlock: show content, hide overlays if (rankingsContent) rankingsContent.style.display = ''; if (rankingsLock) rankingsLock.style.display = 'none'; if (rankingsLockIcon) rankingsLockIcon.style.display = 'none'; if (sessionsContent) sessionsContent.style.display = ''; if (sessionsLock) sessionsLock.style.display = 'none'; if (sessionsLockIcon) sessionsLockIcon.style.display = 'none'; } else { // Lock: hide content, show overlays if (rankingsContent) rankingsContent.style.display = 'none'; if (rankingsLock) rankingsLock.style.display = ''; if (rankingsLockIcon) rankingsLockIcon.style.display = ''; if (sessionsContent) sessionsContent.style.display = 'none'; if (sessionsLock) sessionsLock.style.display = ''; if (sessionsLockIcon) sessionsLockIcon.style.display = ''; } } switchTab(tabName) { this.currentTab = tabName; // Update nav buttons document.querySelectorAll('.nav-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.tab === tabName); }); // Sync mobile drawer items document.querySelectorAll('.drawer-item[data-tab]').forEach(item => { item.classList.toggle('active', item.dataset.tab === tabName); }); // Show/hide tab content document.querySelectorAll('.tab-content').forEach(content => { content.classList.add('hidden'); }); document.getElementById(`tab-${tabName}`)?.classList.remove('hidden'); // Load tab-specific data switch (tabName) { case 'statistics': this.loadStatistics(); if (this.hasAdvancedStats) { this.loadRankings(this.currentRankingType); this.loadTeamRankings(this.currentRankingType); this.loadSessions(); } break; case 'track-editor': // Wait for tab to be fully visible, then initialize requestAnimationFrame(() => { setTimeout(() => { if (window.trackEditor) { if (!window.trackEditor.isInitialized) { console.log('Initializing track editor (first time or retry)'); if (window.trackEditor.map) { window.trackEditor.reset(); } window.trackEditor.initialize(); } else if (window.trackEditor.map) { console.log('Track editor already initialized, resizing'); window.trackEditor.map.resize(); setTimeout(() => window.trackEditor.map.resize(), 100); } } }, 50); }); break; case 'carcache-editor': requestAnimationFrame(() => { setTimeout(() => { if (window.carcacheEditor) { if (!window.carcacheEditor.isInitialized) { window.carcacheEditor.initialize(); } else if (window.carcacheEditor.map) { window.carcacheEditor.map.resize(); } } }, 50); }); break; } } async loadStatistics() { const user = auth.currentUser; if (!user) return; try { // Load all statistics data const statsRef = database.ref(`statistics/${user.uid}`); const snapshot = await statsRef.once('value'); const stats = snapshot.val() || {}; // Load credits const creditsRef = database.ref(`credits/${user.uid}`); const creditsSnapshot = await creditsRef.once('value'); const credits = creditsSnapshot.val() || {}; // Load transport stats const transportRef = database.ref(`statistics/${user.uid}/transport`); const transportSnapshot = await transportRef.once('value'); const transportData = transportSnapshot.val() || {}; // Load car data for VMax const carRef = database.ref(`cars/${user.uid}`); const carSnapshot = await carRef.once('value'); const carData = carSnapshot.val() || {}; // Calculate totals from daily stats let totalDistance = 0; let totalDurationFromDaily = 0; let totalSessions = 0; // allTimeVMax from cars/{uid} is the Single Source of Truth for VMax let overallMaxSpeed = carData.allTimeVMax || 0; if (stats.daily) { Object.values(stats.daily).forEach(day => { totalDistance += day.distanceKm || 0; totalDurationFromDaily += day.totalDuration || 0; totalSessions += day.sessions || 0; }); } // Calculate total duration from SESSIONS (more accurate than daily aggregates) // Daily totalDuration may not include all driving time let totalDurationFromSessions = 0; if (stats.sessions) { Object.values(stats.sessions).forEach(session => { totalDurationFromSessions += session.durationMinutes || 0; }); } // Use the HIGHER value (sessions are more accurate for tracked time) // But also estimate non-session driving time based on distance // Average speed assumption: 40 km/h for untracked time const estimatedTotalMinutes = (totalDistance / 40) * 60; // Distance / avg speed * 60 const totalDuration = Math.max(totalDurationFromDaily, totalDurationFromSessions, estimatedTotalMinutes * 0.7); console.log('Duration calculation:', { fromDaily: totalDurationFromDaily, fromSessions: totalDurationFromSessions, estimated: estimatedTotalMinutes, final: totalDuration }); // Update UI document.getElementById('stat-vmax').textContent = overallMaxSpeed > 0 ? `${Math.round(overallMaxSpeed)} km/h` : '-'; document.getElementById('stat-distance').textContent = totalDistance > 0 ? `${totalDistance.toFixed(1)} km` : '-'; document.getElementById('stat-time').textContent = totalDuration > 0 ? this.formatDuration(totalDuration) : '-'; document.getElementById('stat-sessions').textContent = totalSessions || '0'; // Count challenges won from sessions if available let challengesWon = 0; if (stats.sessions) { Object.values(stats.sessions).forEach(session => { if (session.challengeWon) challengesWon++; }); } document.getElementById('stat-challenges').textContent = challengesWon; document.getElementById('stat-credits').textContent = credits.balance ? this.formatNumber(credits.balance) : '0'; // Calculate transport mode totals // Same logic as app: carKm = totalDistance - trainKm - airplaneKm - walkingKm let trackedCarKm = 0; let totalTrainKm = 0; let totalAirplaneKm = 0; let totalWalkingKm = 0; if (transportData) { Object.values(transportData).forEach(day => { trackedCarKm += day.carKm || 0; totalTrainKm += day.trainKm || 0; totalAirplaneKm += day.airplaneKm || 0; totalWalkingKm += day.walkingKm || 0; }); } // Calculate car km: total distance minus train, airplane and walking (like the app does) const calculatedCarKm = Math.max(0, totalDistance - totalTrainKm - totalAirplaneKm - totalWalkingKm); // Use the higher value between tracked and calculated const totalCarKm = Math.max(trackedCarKm, calculatedCarKm); // Update transport UI (with null checks) const elCarKm = document.getElementById('stat-car-km'); const elTrainKm = document.getElementById('stat-train-km'); const elAirplaneKm = document.getElementById('stat-airplane-km'); const elWalkingKm = document.getElementById('stat-walking-km'); if (elCarKm) elCarKm.textContent = `${totalCarKm.toFixed(1)} km`; if (elTrainKm) elTrainKm.textContent = `${totalTrainKm.toFixed(1)} km`; if (elAirplaneKm) elAirplaneKm.textContent = `${totalAirplaneKm.toFixed(1)} km`; if (elWalkingKm) elWalkingKm.textContent = `${totalWalkingKm.toFixed(1)} km`; console.log('Statistics loaded:', { totalDistance, totalDuration, totalSessions, overallMaxSpeed, totalCarKm, totalTrainKm, totalAirplaneKm, totalWalkingKm }); // Load activity chart this.loadActivityChart(stats.daily || {}); } catch (error) { console.error('Error loading statistics:', error); } } loadActivityChart(dailyStats) { const ctx = document.getElementById('activity-chart'); if (!ctx) { console.warn('Activity chart canvas not found'); return; } // Check if Chart.js is loaded if (typeof Chart === 'undefined') { console.warn('Chart.js not loaded'); ctx.parentElement.innerHTML = '
Chart konnte nicht geladen werden
'; return; } // Destroy existing chart if (this.activityChart) { this.activityChart.destroy(); } // Log available daily stats keys to help debug const availableKeys = Object.keys(dailyStats); console.log('Available daily stats keys:', availableKeys.slice(0, 10)); // Get last 14 days of data const days = []; const distanceData = []; const sessionsData = []; let hasAnyData = false; for (let i = 13; i >= 0; i--) { const date = new Date(); date.setDate(date.getDate() - i); // Try multiple date formats const dateKeyISO = date.toISOString().split('T')[0]; // YYYY-MM-DD const dateKeyDE = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; // DD.MM.YYYY const dateKeyShort = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`; // YYYY-MM-DD (alternative) const dayData = dailyStats[dateKeyISO] || dailyStats[dateKeyDE] || dailyStats[dateKeyShort] || {}; if (dayData.distanceKm || dayData.sessions) { hasAnyData = true; } days.push(date.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric' })); distanceData.push(parseFloat(dayData.distanceKm) || 0); sessionsData.push(parseInt(dayData.sessions) || 0); } console.log('Chart data:', { days, distanceData, sessionsData, hasAnyData }); // If no data, show message if (!hasAnyData && availableKeys.length === 0) { ctx.parentElement.innerHTML = '
Keine Aktivitätsdaten vorhanden.
Starte eine Session in der App um Daten zu sammeln.
'; return; } // Create chart this.activityChart = new Chart(ctx, { type: 'bar', data: { labels: days, datasets: [ { label: 'Strecke (km)', data: distanceData, backgroundColor: 'rgba(0, 217, 255, 0.7)', borderColor: 'rgba(0, 217, 255, 1)', borderWidth: 1, borderRadius: 4, yAxisID: 'y' }, { label: 'Sessions', data: sessionsData, backgroundColor: 'rgba(255, 149, 0, 0.7)', borderColor: 'rgba(255, 149, 0, 1)', borderWidth: 1, borderRadius: 4, yAxisID: 'y1' } ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false, }, plugins: { legend: { position: 'top', labels: { color: '#b3b3b3', font: { size: 12 } } }, tooltip: { backgroundColor: 'rgba(26, 26, 26, 0.95)', titleColor: '#fff', bodyColor: '#b3b3b3', borderColor: '#333', borderWidth: 1 } }, scales: { x: { grid: { color: 'rgba(255, 255, 255, 0.1)' }, ticks: { color: '#808080', font: { size: 10 } } }, y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'Strecke (km)', color: '#00d9ff' }, grid: { color: 'rgba(255, 255, 255, 0.1)' }, ticks: { color: '#00d9ff' } }, y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'Sessions', color: '#ff9500' }, grid: { drawOnChartArea: false, }, ticks: { color: '#ff9500', stepSize: 1 } } } } }); } async loadSessions() { const user = auth.currentUser; if (!user) return; const container = document.getElementById('sessions-list'); container.innerHTML = '
Lade Sessions...
'; try { const sessionsRef = database.ref(`statistics/${user.uid}/sessions`); const snapshot = await sessionsRef.orderByChild('startTime').once('value'); const sessionsData = snapshot.val(); if (!sessionsData) { container.innerHTML = '
Keine Sessions gefunden
'; return; } // Convert to array and sort by date (newest first) this.sessions = Object.entries(sessionsData) .map(([id, data]) => ({ id, ...data })) .sort((a, b) => (b.startTime || 0) - (a.startTime || 0)); this.renderSessions(); } catch (error) { console.error('Error loading sessions:', error); container.innerHTML = '
Fehler beim Laden der Sessions
'; } } renderSessions() { const container = document.getElementById('sessions-list'); if (this.sessions.length === 0) { container.innerHTML = '
Keine Sessions gefunden
'; return; } // Group sessions by month const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; const grouped = {}; this.sessions.forEach(session => { const d = new Date(session.startTime || 0); const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; if (!grouped[key]) grouped[key] = []; grouped[key].push(session); }); // Sort month keys descending (newest first) const sortedKeys = Object.keys(grouped).sort((a, b) => b.localeCompare(a)); // Current month key for auto-expand const now = new Date(); const currentMonthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; container.innerHTML = sortedKeys.map((monthKey, idx) => { const sessions = grouped[monthKey]; const [year, month] = monthKey.split('-'); const monthLabel = `${monthNames[parseInt(month) - 1]} ${year}`; const totalKm = sessions.reduce((sum, s) => sum + (s.distanceKm || 0), 0); const topSpeed = Math.max(...sessions.map(s => s.maxSpeed || 0)); const isOpen = monthKey === currentMonthKey || idx === 0; return `
${isOpen ? 'expand_more' : 'chevron_right'} ${monthLabel}
${sessions.length} ${sessions.length === 1 ? 'Fahrt' : 'Fahrten'} · ${totalKm.toFixed(1)} km · V-Max ${topSpeed.toFixed(0)} km/h
${sessions.map(session => `
${this.formatDate(session.startTime)}
${session.distanceKm?.toFixed(2) || '0'} km Strecke
${session.durationMinutes?.toFixed(0) || '0'} min Dauer
${session.maxSpeed?.toFixed(0) || '0'} km/h Max Speed
${session.avgSpeed?.toFixed(0) || '0'} km/h Avg Speed
chevron_right
`).join('')}
`; }).join(''); // Month header toggle handlers container.querySelectorAll('.session-month-header').forEach(header => { header.addEventListener('click', () => { const body = header.nextElementSibling; const chevron = header.querySelector('.session-month-chevron'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'flex'; chevron.textContent = isOpen ? 'chevron_right' : 'expand_more'; header.classList.toggle('open', !isOpen); }); }); // Session card click handlers container.querySelectorAll('.session-card').forEach(card => { card.addEventListener('click', () => { const sessionId = card.dataset.sessionId; const session = this.sessions.find(s => s.id === sessionId); if (session) { this.showSessionDetail(session); } }); }); } showSessionDetail(session) { this.selectedSession = session; // Create modal const modal = document.createElement('div'); modal.id = 'session-detail-modal'; modal.className = 'modal-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); // Close button handler document.getElementById('close-session-detail').addEventListener('click', () => { this.closeSessionDetail(); }); // Click outside to close modal.addEventListener('click', (e) => { if (e.target === modal) { this.closeSessionDetail(); } }); // Initialize map with session track setTimeout(() => { this.initSessionMap(session); }, 100); } initSessionMap(session) { const container = document.getElementById('session-detail-map'); if (!container) { console.error('Session map container not found'); return; } // Ensure container has explicit dimensions console.log('Session map container dimensions:', container.offsetWidth, 'x', container.offsetHeight); if (container.offsetWidth === 0 || container.offsetHeight === 0) { console.warn('Container has no dimensions, setting explicit size'); container.style.width = '100%'; container.style.height = '400px'; } // Check if locationHistory exists and has data if (!session.locationHistory || session.locationHistory.length === 0) { console.log('No GPS data in session:', session); container.innerHTML = '
Keine GPS-Daten verfügbar
'; return; } // Check if Mapbox is loaded if (typeof mapboxgl === 'undefined') { console.error('Mapbox GL JS not loaded'); container.innerHTML = '
Mapbox konnte nicht geladen werden
'; return; } console.log('Initializing session map with', session.locationHistory.length, 'GPS points'); try { mapboxgl.accessToken = 'pk.eyJ1IjoiZnJhbmNvaXNyZWluZXJ0IiwiYSI6ImNtazBja2liYjBvbnozZnM4bWE2OGF0b3UifQ.3k-VptMsIpvD0ys4igFWeg'; // Calculate bounds const bounds = new mapboxgl.LngLatBounds(); let validPoints = 0; session.locationHistory.forEach(point => { if (point.longitude && point.latitude && Math.abs(point.longitude) <= 180 && Math.abs(point.latitude) <= 90) { bounds.extend([point.longitude, point.latitude]); validPoints++; } }); console.log('Valid GPS points:', validPoints, 'of', session.locationHistory.length); // Check if bounds are valid if (validPoints > 0 && !bounds.isEmpty()) { this.sessionMap = new mapboxgl.Map({ container: 'session-detail-map', style: 'mapbox://styles/mapbox/dark-v11', bounds: bounds, fitBoundsOptions: { padding: 50 }, attributionControl: true, failIfMajorPerformanceCaveat: false }); } else { // Fallback: use first point as center const firstPoint = session.locationHistory.find(p => p.longitude && p.latitude); if (firstPoint) { this.sessionMap = new mapboxgl.Map({ container: 'session-detail-map', style: 'mapbox://styles/mapbox/dark-v11', center: [firstPoint.longitude, firstPoint.latitude], zoom: 14, attributionControl: true, failIfMajorPerformanceCaveat: false }); } else { container.innerHTML = '
Keine gültigen GPS-Daten
'; return; } } this.sessionMap.on('load', () => { // Create line segments with speed-based colors const segments = this.createSpeedColoredSegments(session.locationHistory); // Add each segment as a separate layer segments.forEach((segment, index) => { this.sessionMap.addSource(`segment-${index}`, { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: segment.coordinates } } }); this.sessionMap.addLayer({ id: `segment-layer-${index}`, type: 'line', source: `segment-${index}`, paint: { 'line-color': segment.color, 'line-width': 4, 'line-opacity': 0.9 } }); }); // Add start marker const startPoint = session.locationHistory[0]; new mapboxgl.Marker({ color: '#00ff00' }) .setLngLat([startPoint.longitude, startPoint.latitude]) .setPopup(new mapboxgl.Popup().setHTML('Start')) .addTo(this.sessionMap); // Add end marker const endPoint = session.locationHistory[session.locationHistory.length - 1]; new mapboxgl.Marker({ color: '#ff0000' }) .setLngLat([endPoint.longitude, endPoint.latitude]) .setPopup(new mapboxgl.Popup().setHTML('Ende')) .addTo(this.sessionMap); // Add max speed marker let maxSpeedPoint = null; let maxSpeed = 0; session.locationHistory.forEach(point => { if (point.speed > maxSpeed) { maxSpeed = point.speed; maxSpeedPoint = point; } }); if (maxSpeedPoint) { const el = document.createElement('div'); el.className = 'max-speed-marker'; el.innerHTML = `speed${Math.round(maxSpeed)} km/h`; el.style.cssText = ` background: rgba(255, 68, 68, 0.9); color: white; padding: 4px 8px; border-radius: 4px; font-weight: bold; display: flex; align-items: center; gap: 4px; font-size: 12px; `; new mapboxgl.Marker({ element: el }) .setLngLat([maxSpeedPoint.longitude, maxSpeedPoint.latitude]) .addTo(this.sessionMap); } // Clickable GPS points layer for speed tooltips const pointFeatures = session.locationHistory .filter(p => p.longitude && p.latitude && Math.abs(p.longitude) <= 180 && Math.abs(p.latitude) <= 90) .map(p => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [p.longitude, p.latitude] }, properties: { speed: Math.round(p.speed || 0), color: this.getSpeedColor(p.speed || 0), time: p.timestamp ? new Date(p.timestamp).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '' } })); this.sessionMap.addSource('gps-points', { type: 'geojson', data: { type: 'FeatureCollection', features: pointFeatures } }); this.sessionMap.addLayer({ id: 'gps-points-layer', type: 'circle', source: 'gps-points', paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 10, 2, 14, 4, 18, 7], 'circle-color': ['get', 'color'], 'circle-opacity': 0, 'circle-stroke-width': 0 } }); // Hover: show points and change cursor this.sessionMap.on('mouseenter', 'gps-points-layer', () => { this.sessionMap.getCanvas().style.cursor = 'pointer'; this.sessionMap.setPaintProperty('gps-points-layer', 'circle-opacity', 0.8); this.sessionMap.setPaintProperty('gps-points-layer', 'circle-stroke-width', 1); }); this.sessionMap.on('mouseleave', 'gps-points-layer', () => { this.sessionMap.getCanvas().style.cursor = ''; this.sessionMap.setPaintProperty('gps-points-layer', 'circle-opacity', 0); this.sessionMap.setPaintProperty('gps-points-layer', 'circle-stroke-width', 0); }); // Click: show speed popup const speedPopup = new mapboxgl.Popup({ closeButton: false, closeOnClick: true, offset: 10, className: 'speed-popup-dark' }); this.sessionMap.on('click', 'gps-points-layer', (e) => { const props = e.features[0].properties; const coords = e.features[0].geometry.coordinates; speedPopup .setLngLat(coords) .setHTML(`
${props.speed} km/h
${props.time ? `
${props.time}
` : ''}`) .addTo(this.sessionMap); }); console.log('Session map loaded successfully'); // Force resize to ensure proper rendering this.sessionMap.resize(); setTimeout(() => this.sessionMap.resize(), 100); setTimeout(() => this.sessionMap.resize(), 500); }); this.sessionMap.on('error', (e) => { console.error('Mapbox error:', e); if (e.error && e.error.status === 401) { container.innerHTML = '
Mapbox Token ungültig
'; } }); // Debug logging this.sessionMap.on('idle', () => { console.log('Session map idle (fully rendered)'); }); } catch (error) { console.error('Error initializing session map:', error); container.innerHTML = `
Fehler beim Laden der Karte: ${error.message}
`; } } createSpeedColoredSegments(locationHistory) { const segments = []; let currentSegment = null; for (let i = 0; i < locationHistory.length - 1; i++) { const point = locationHistory[i]; const nextPoint = locationHistory[i + 1]; const speed = point.speed || 0; const color = this.getSpeedColor(speed); if (!currentSegment || currentSegment.color !== color) { if (currentSegment) { segments.push(currentSegment); } currentSegment = { color: color, coordinates: [[point.longitude, point.latitude]] }; } currentSegment.coordinates.push([nextPoint.longitude, nextPoint.latitude]); } if (currentSegment) { segments.push(currentSegment); } return segments; } getSpeedColor(speed) { if (speed < 30) return '#00ff00'; // Green if (speed < 50) return '#88ff00'; // Light green if (speed < 80) return '#ffff00'; // Yellow if (speed < 120) return '#ff8800'; // Orange return '#ff0000'; // Red } closeSessionDetail() { const modal = document.getElementById('session-detail-modal'); if (modal) { modal.remove(); } if (this.sessionMap) { this.sessionMap.remove(); this.sessionMap = null; } this.selectedSession = null; } async loadRankings(type) { const container = document.getElementById('rankings-table'); container.innerHTML = '
Lade Ranglisten...
'; try { // Map UI type to Firebase leaderboard category const categoryMap = { speed: 'topSpeed', distance: 'totalKm', challenges: 'challenges', acceleration: 'acceleration' }; const category = categoryMap[type] || 'topSpeed'; // Build path based on period let path; if (this.currentRankingPeriod === 'monthly') { const now = new Date(); const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; path = `leaderboards/monthly/${monthKey}/${category}`; } else { path = `leaderboards/allTime/${category}`; } // Load pre-computed leaderboard data const lbRef = database.ref(path); const snapshot = await lbRef.once('value'); const lbData = snapshot.val(); if (!lbData) { container.innerHTML = '
Keine Ranglisten verfügbar
'; return; } // Create ranking array from leaderboard entries {v, n, img, t} let rankings = Object.entries(lbData) .map(([uid, entry]) => ({ uid, nickname: entry.n || '', value: parseFloat(entry.v) || 0, })) .filter(u => u.value > 0); // Resolve missing nicknames from cars/ node const unknowns = rankings.filter(e => !e.nickname || e.nickname === 'Unbekannt'); if (unknowns.length > 0) { await Promise.all(unknowns.map(async (entry) => { try { const snap = await database.ref(`cars/${entry.uid}/nickname`).once('value'); const nick = snap.val(); if (nick) entry.nickname = nick; } catch { /* ignore */ } })); } // Final fallback rankings.forEach(e => { if (!e.nickname) e.nickname = 'Unbekannt'; }); // Sort: ascending for acceleration (lower = better), descending for others if (type === 'acceleration') { rankings.sort((a, b) => a.value - b.value); } else { rankings.sort((a, b) => b.value - a.value); } rankings = rankings.slice(0, 50); // Format value based on type const formatValue = (val) => { switch (type) { case 'speed': return `${Math.round(val)} km/h`; case 'distance': return `${val.toFixed(1)} km`; case 'challenges': return `${Math.round(val)}`; case 'acceleration': return `${val.toFixed(1)} s`; default: return val; } }; const columnLabel = { speed: 'VMax', distance: 'Strecke', challenges: 'Challenges', acceleration: '0-100' }; // Render container.innerHTML = `
Rang
Fahrer
${columnLabel[type] || 'Wert'}
${rankings.map((user, index) => `
#${index + 1}
${user.nickname}
${formatValue(user.value)}
`).join('')} `; } catch (error) { console.error('Error loading rankings:', error); container.innerHTML = '
Fehler beim Laden
'; } } async loadTeamRankings(type) { const section = document.getElementById('team-rankings-section'); const container = document.getElementById('team-rankings-table'); const periodLabel = document.getElementById('team-period-label'); try { const categoryMap = { speed: 'topSpeed', distance: 'totalKm', challenges: 'challenges' }; const category = categoryMap[type] || 'topSpeed'; const now = new Date(); const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; const isMonthly = this.currentRankingPeriod === 'monthly'; let path; if (isMonthly) { path = `teamLeaderboards/monthly/${monthKey}/${category}`; periodLabel.textContent = `(${monthKey})`; } else { path = `teamLeaderboards/allTime/${category}`; periodLabel.textContent = '(Gesamt)'; } const snapshot = await database.ref(path).once('value'); let entries = []; if (snapshot.exists()) { entries = Object.entries(snapshot.val()) .map(([teamId, e]) => ({ teamId, ...e })) .filter(e => (e.v || 0) > 0) .sort((a, b) => (b.v || 0) - (a.v || 0)); } // Fallback: compute from teamStats/members if teamLeaderboards is empty if (entries.length === 0) { const [teamsSnap, statsSnap] = await Promise.all([ database.ref('teams').once('value'), database.ref('teamStats').once('value'), ]); if (teamsSnap.exists() && statsSnap.exists()) { const teams = teamsSnap.val(); const allStats = statsSnap.val(); for (const [teamId, team] of Object.entries(teams)) { const stats = allStats[teamId]; if (!stats?.members) continue; let value = 0; let bestName = ''; const members = stats.members; for (const [uid, m] of Object.entries(members)) { let memberVal = 0; if (type === 'speed') memberVal = isMonthly ? (m.monthlyVMax || 0) : (m.allTimeVMax || 0); else if (type === 'distance') memberVal = isMonthly ? (m.monthlyKm || 0) : (m.allTimeKm || 0); else if (type === 'challenges') memberVal = isMonthly ? (m.monthlyChallengesWon || 0) : (m.allTimeChallengesWon || 0); if (type === 'speed') { if (memberVal > value) { value = memberVal; bestName = m.nickname || ''; } } else { value += memberVal; } } // For speed: if teamStats/members has no VMax data, look up individual user data if (type === 'speed' && value === 0 && team.members) { for (const uid of Object.keys(team.members)) { try { let vmax = 0; if (isMonthly) { const lbSnap = await database.ref(`leaderboards/monthly/${monthKey}/topSpeed/${uid}`).once('value'); vmax = parseFloat(lbSnap.val()?.v) || 0; } if (!vmax) { const carSnap = await database.ref(`cars/${uid}/allTimeVMax`).once('value'); vmax = parseFloat(carSnap.val()) || 0; } if (vmax > value) { value = vmax; const nickSnap = await database.ref(`cars/${uid}/nickname`).once('value'); bestName = nickSnap.val() || ''; } } catch (e) { /* skip */ } } } if (value > 0) { entries.push({ teamId, name: team.name || teamId, emoji: team.emoji || '', memberCount: team.members ? Object.keys(team.members).length : 0, v: value, bestMemberName: type === 'speed' ? bestName : '', }); } } entries.sort((a, b) => (b.v || 0) - (a.v || 0)); } } section.style.display = 'block'; if (entries.length === 0) { container.innerHTML = '
Keine Team-Einträge
'; return; } const formatValue = (val) => { switch (type) { case 'speed': return `${Math.round(val)} km/h`; case 'distance': return `${val.toFixed(1)} km`; case 'challenges': return `${Math.round(val)}`; default: return val; } }; container.innerHTML = `
Rang
Team
${type === 'speed' ? 'VMax' : type === 'distance' ? 'Strecke' : 'Challenges'}
${entries.map((e, i) => `
#${i + 1}
${e.emoji || ''} ${e.name || e.teamId}${e.memberCount ? ` (${e.memberCount})` : ''}${e.bestMemberName ? ` ★ ${e.bestMemberName}` : ''}
${formatValue(e.v || 0)}
`).join('')} `; } catch (error) { console.error('Error loading team rankings:', error); section.style.display = 'block'; container.innerHTML = '
Fehler beim Laden
'; } } async toggleTeamMembers(teamId, type, rowEl) { const membersDiv = document.getElementById(`team-members-${teamId}`); if (!membersDiv) return; // Toggle visibility if (membersDiv.style.display !== 'none') { membersDiv.style.display = 'none'; return; } membersDiv.innerHTML = '
Lade Mitglieder...
'; membersDiv.style.display = 'block'; try { const snap = await database.ref(`teamStats/${teamId}/members`).once('value'); if (!snap.exists()) { membersDiv.innerHTML = '
Keine Mitglieder-Daten
'; return; } const members = snap.val(); const isMonthly = this.currentRankingPeriod === 'monthly'; const now = new Date(); const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; // Map member data to sortable array based on category let memberList = Object.entries(members).map(([uid, m]) => { let value = 0; if (type === 'distance') value = isMonthly ? (m.monthlyKm || 0) : (m.allTimeKm || 0); else if (type === 'speed') value = isMonthly ? (m.monthlyVMax || 0) : (m.allTimeVMax || 0); else if (type === 'challenges') value = isMonthly ? (m.monthlyChallengesWon || 0) : (m.allTimeChallengesWon || 0); return { uid, nickname: m.nickname || uid.substring(0, 8), value }; }); // For speed: if members have no VMax data, look up individual sources if (type === 'speed' && memberList.every(m => m.value === 0)) { for (const m of memberList) { try { let vmax = 0; if (isMonthly) { const lbSnap = await database.ref(`leaderboards/monthly/${monthKey}/topSpeed/${m.uid}`).once('value'); vmax = parseFloat(lbSnap.val()?.v) || 0; } if (!vmax) { const carSnap = await database.ref(`cars/${m.uid}/allTimeVMax`).once('value'); vmax = parseFloat(carSnap.val()) || 0; } m.value = vmax; } catch (e) { /* skip */ } } } memberList.sort((a, b) => b.value - a.value); const formatVal = (val) => { if (type === 'speed') return `${Math.round(val)} km/h`; if (type === 'distance') return `${val.toFixed(1)} km`; return `${Math.round(val)}`; }; membersDiv.innerHTML = memberList.map(m => `
${m.nickname}
${formatVal(m.value)}
`).join(''); } catch (error) { console.error('Error loading team members:', error); membersDiv.innerHTML = '
Fehler beim Laden
'; } } // Utility functions formatDate(timestamp) { if (!timestamp) return '-'; const date = new Date(timestamp); return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } formatDateFull(timestamp) { if (!timestamp) return '-'; const date = new Date(timestamp); return date.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } formatDuration(minutes) { if (!minutes) return '-'; const hours = Math.floor(minutes / 60); const mins = Math.round(minutes % 60); return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; } formatNumber(num) { if (!num) return '0'; return num.toLocaleString('de-DE'); } } // Initialize dashboard window.dashboard = new Dashboard();