/**
* 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 `
${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 = `
route
${session.distanceKm?.toFixed(2) || '0'} km
Strecke
timer
${session.durationMinutes?.toFixed(0) || '0'} min
Dauer
speed
${session.maxSpeed?.toFixed(0) || '0'} km/h
Max Speed
trending_up
${session.avgSpeed?.toFixed(0) || '0'} km/h
Avg Speed
Geschwindigkeits-Farben
0-30 km/h
30-50 km/h
50-80 km/h
80-120 km/h
120+ km/h
gps_fixed
${session.locationHistory?.length || 0} GPS-Punkte aufgezeichnet
`;
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 = `
${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 = `
${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();