(() => { const API_URL = 'https://fb.srv.medeba-media.de/api'; const LOGIN_PAGE = 'login.html'; let posts = []; let filteredPosts = []; let currentTimeFilter = 'week'; let currentProfileFilter = 'all'; const DAY_IN_MS = 24 * 60 * 60 * 1000; const HOUR_IN_MS = 60 * 60 * 1000; function handleUnauthorized(response) { if (response && response.status === 401) { if (typeof redirectToLogin === 'function') { redirectToLogin(); } else { window.location.href = LOGIN_PAGE; } return true; } return false; } function startOfDay(date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } function addDays(date, days) { const result = new Date(date.getTime()); result.setDate(result.getDate() + days); return result; } function addMonths(date, months) { return new Date(date.getFullYear(), date.getMonth() + months, 1); } function getPostDisplayTitle(post) { if (post.title && post.title.trim()) { return post.title.trim(); } if (post.created_by_name && post.created_by_name.trim()) { let creatorName = post.created_by_name.trim(); // Remove ", Story ansehen" suffix if present if (creatorName.endsWith(', Story ansehen')) { creatorName = creatorName.slice(0, -16).trim(); } return creatorName; } return 'Unbekannt'; } function matchesProfileFilter(post) { if (currentProfileFilter === 'all') { return true; } const profileNum = parseInt(currentProfileFilter, 10); if (Number.isNaN(profileNum)) { return true; } if (post.created_by_profile === profileNum) { return true; } if (Array.isArray(post.required_profiles)) { const requiredMatch = post.required_profiles.some((value) => parseInt(value, 10) === profileNum); if (requiredMatch) { return true; } } if (Array.isArray(post.checks)) { const checkMatch = post.checks.some(check => check && parseInt(check.profile_number, 10) === profileNum); if (checkMatch) { return true; } } return false; } function getFilterStartDate(filter, now = new Date()) { switch (filter) { case 'today': return startOfDay(now); case 'week': return addDays(startOfDay(now), -7); case 'month': return new Date(now.getFullYear(), now.getMonth(), 1); case 'year': return new Date(now.getFullYear(), 0, 1); case 'all': default: return null; } } function getComparisonConfig() { const now = new Date(); const todayStart = startOfDay(now); switch (currentTimeFilter) { case 'today': { const currentStart = todayStart; const currentEnd = addDays(currentStart, 1); const previousStart = addDays(currentStart, -1); const previousEnd = currentStart; return { granularity: 'hour', bucketCount: 24, currentStart, currentEnd, previousStart, previousEnd, currentLabel: 'Heute', previousLabel: 'Gestern', rangeDescription: '24-Stunden-Vergleich' }; } case 'week': { const currentEnd = addDays(todayStart, 1); const currentStart = addDays(currentEnd, -7); const previousEnd = currentStart; const previousStart = addDays(previousEnd, -7); return { granularity: 'day', bucketCount: 7, currentStart, currentEnd, previousStart, previousEnd, currentLabel: 'Aktuelle 7 Tage', previousLabel: 'Vorherige 7 Tage', rangeDescription: '7-Tage-Vergleich' }; } case 'month': { const currentEnd = addDays(todayStart, 1); const currentStart = addDays(currentEnd, -30); const previousEnd = currentStart; const previousStart = addDays(previousEnd, -30); return { granularity: 'day', bucketCount: 30, currentStart, currentEnd, previousStart, previousEnd, currentLabel: 'Aktuelle 30 Tage', previousLabel: 'Vorherige 30 Tage', rangeDescription: '30-Tage-Rollierend' }; } case 'year': { const currentEnd = addMonths(new Date(now.getFullYear(), now.getMonth(), 1), 1); const currentStart = addMonths(currentEnd, -12); const previousEnd = currentStart; const previousStart = addMonths(previousEnd, -12); return { granularity: 'month', bucketCount: 12, currentStart, currentEnd, previousStart, previousEnd, currentLabel: 'Aktuelle 12 Monate', previousLabel: 'Vorherige 12 Monate', rangeDescription: '12-Monats-Vergleich' }; } case 'all': default: { const currentEnd = addDays(todayStart, 1); const currentStart = addDays(currentEnd, -30); const previousEnd = currentStart; const previousStart = addDays(previousEnd, -30); return { granularity: 'day', bucketCount: 30, currentStart, currentEnd, previousStart, previousEnd, currentLabel: 'Aktuelle 30 Tage', previousLabel: 'Vorherige 30 Tage', rangeDescription: 'Rollierender Zeitraum' }; } } } function countChecksBetween(start, end, targetProfile = null) { let sum = 0; posts.forEach(post => { if (!matchesProfileFilter(post)) { return; } if (!Array.isArray(post.checks)) { return; } post.checks.forEach(check => { if (!check || !check.checked_at) { return; } const profileNumber = parseInt(check.profile_number, 10); if (targetProfile && profileNumber !== targetProfile) { return; } const timestamp = new Date(check.checked_at); if (timestamp >= start && timestamp < end) { sum += 1; } }); }); return sum; } function buildTrendSeries(start, bucketCount, granularity) { const labels = []; const values = []; const formatterDay = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit' }); const formatterMonth = new Intl.DateTimeFormat('de-DE', { month: 'short' }); for (let index = 0; index < bucketCount; index++) { let bucketStart; let bucketEnd; let label; if (granularity === 'hour') { bucketStart = new Date(start.getTime() + index * HOUR_IN_MS); bucketEnd = new Date(bucketStart.getTime() + HOUR_IN_MS); label = `${String(bucketStart.getHours()).padStart(2, '0')}h`; } else if (granularity === 'month') { bucketStart = addMonths(start, index); bucketEnd = addMonths(start, index + 1); label = formatterMonth.format(bucketStart); } else { bucketStart = addDays(start, index); bucketEnd = addDays(bucketStart, 1); label = formatterDay.format(bucketStart); } labels.push(label); values.push(countChecksBetween(bucketStart, bucketEnd)); } return { labels, values }; } function countChecksPerProfile(start, end) { const counts = [0, 0, 0, 0, 0]; const targetProfile = currentProfileFilter !== 'all' ? parseInt(currentProfileFilter, 10) : null; posts.forEach(post => { if (!matchesProfileFilter(post)) { return; } if (!Array.isArray(post.checks)) { return; } post.checks.forEach(check => { if (!check || !check.checked_at) { return; } const profileNumber = parseInt(check.profile_number, 10); if (Number.isNaN(profileNumber) || profileNumber < 1 || profileNumber > 5) { return; } if (targetProfile && profileNumber !== targetProfile) { return; } const timestamp = new Date(check.checked_at); if (timestamp >= start && timestamp < end) { counts[profileNumber - 1] += 1; } }); }); return counts; } function formatDurationFromHours(hours) { if (hours == null || Number.isNaN(hours)) { return '-'; } if (hours < 1) { const minutes = Math.max(1, Math.round(hours * 60)); return `${minutes} Min.`; } if (hours < 48) { return `${Math.round(hours)} Std.`; } const days = Math.round(hours / 24); return `${days} Tg.`; } function apiFetch(url, options = {}) { const config = { ...options, credentials: 'include' }; if (options && options.headers) { config.headers = { ...options.headers }; } return fetch(url, config); } function showLoading() { const loading = document.getElementById('dashboardLoading'); if (loading) { loading.style.display = 'block'; } } function hideLoading() { const loading = document.getElementById('dashboardLoading'); if (loading) { loading.style.display = 'none'; } } function showError(message) { const error = document.getElementById('dashboardError'); if (error) { error.textContent = message; error.style.display = 'block'; } } function hideError() { const error = document.getElementById('dashboardError'); if (error) { error.style.display = 'none'; } } function applyFilters() { const now = new Date(); const timeStart = getFilterStartDate(currentTimeFilter, now); filteredPosts = posts.filter(post => { // Time filter if (timeStart && post.created_at) { if (new Date(post.created_at) < timeStart) { return false; } } return matchesProfileFilter(post); }); renderDashboard(); } async function fetchPosts() { try { showLoading(); hideError(); const response = await apiFetch(`${API_URL}/posts`); if (!response.ok) { throw new Error('Konnte Beiträge nicht laden'); } posts = await response.json(); applyFilters(); } catch (error) { console.error('Fehler beim Laden der Beiträge:', error); showError('Fehler beim Laden der Statistiken'); } finally { hideLoading(); } } function renderDashboard() { const container = document.getElementById('dashboardContainer'); if (container) { container.style.display = 'flex'; } // Section 1: Overview calculateOverviewStats(); renderKeyMetrics(); // Section 2: Analytics renderTimelineChart(); renderProfileChart(); renderProgressChart(); renderPeriodTrendChart(); renderProfileComparisonChart(); // Section 3: Performance Comparisons renderComparisons(); renderSuccessAnalysis(); // Section 4: Details renderTopPerformers(); renderUpcomingDeadlines(); renderRecentActivity(); } function calculateOverviewStats() { const now = new Date(); const completed = filteredPosts.filter(post => post.is_complete).length; const expired = filteredPosts.filter(post => { if (!post.deadline_at) return false; return new Date(post.deadline_at) < now && !post.is_complete; }).length; const active = filteredPosts.filter(post => { const isExpired = post.deadline_at ? new Date(post.deadline_at) < now : false; return !post.is_complete && !isExpired; }).length; const successful = filteredPosts.filter((post) => post.is_successful).length; document.getElementById('totalPosts').textContent = filteredPosts.length; document.getElementById('completedPosts').textContent = completed; document.getElementById('activePosts').textContent = active; document.getElementById('expiredPosts').textContent = expired; document.getElementById('successfulPosts').textContent = successful; } function renderKeyMetrics() { const now = new Date(); const yesterday = new Date(now.getTime() - DAY_IN_MS); // Success Rate Metric const successRateEl = document.getElementById('successRateMetric'); const successRateChangeEl = document.getElementById('successRateChange'); if (successRateEl) { const totalWithDeadline = filteredPosts.filter(post => post.deadline_at).length; if (totalWithDeadline > 0) { const completedBeforeDeadline = filteredPosts.filter(post => { if (!post.deadline_at || !post.is_complete) return false; const deadline = new Date(post.deadline_at); const lastCheck = Array.isArray(post.checks) && post.checks.length > 0 ? new Date(post.checks[post.checks.length - 1].checked_at) : null; return lastCheck && lastCheck <= deadline; }).length; const rate = Math.round((completedBeforeDeadline / totalWithDeadline) * 100); successRateEl.textContent = `${rate}%`; } else { successRateEl.textContent = '-'; } } // Average Completion Time const avgTimeEl = document.getElementById('avgCompletionTime'); if (avgTimeEl) { const completedPosts = filteredPosts.filter(post => post.is_complete); if (completedPosts.length > 0) { let totalHours = 0; let count = 0; completedPosts.forEach(post => { if (post.created_at && Array.isArray(post.checks) && post.checks.length > 0) { const created = new Date(post.created_at); const lastCheck = new Date(post.checks[post.checks.length - 1].checked_at); const hours = (lastCheck - created) / HOUR_IN_MS; totalHours += hours; count++; } }); if (count > 0) { avgTimeEl.textContent = formatDurationFromHours(totalHours / count); } else { avgTimeEl.textContent = '-'; } } else { avgTimeEl.textContent = '-'; } } // Checks Today const checksTodayEl = document.getElementById('checksToday'); if (checksTodayEl) { const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); let checksToday = 0; filteredPosts.forEach(post => { if (Array.isArray(post.checks)) { post.checks.forEach(check => { if (check.checked_at && new Date(check.checked_at) >= todayStart) { checksToday++; } }); } }); checksTodayEl.textContent = checksToday; } // Deadline Risk const deadlineRiskValueEl = document.getElementById('deadlineRiskValue'); const deadlineRiskTextEl = document.getElementById('deadlineRiskText'); if (deadlineRiskValueEl && deadlineRiskTextEl) { const upcomingThreshold = new Date(now.getTime() + DAY_IN_MS); const activeWithDeadline = filteredPosts.filter(post => !post.is_complete && post.deadline_at); if (activeWithDeadline.length === 0) { deadlineRiskValueEl.textContent = '0'; deadlineRiskTextEl.textContent = 'keine Risiken'; } else { let urgent = 0; let overdue = 0; activeWithDeadline.forEach(post => { const deadline = new Date(post.deadline_at); if (deadline <= now) { overdue += 1; } else if (deadline <= upcomingThreshold) { urgent += 1; } }); deadlineRiskValueEl.textContent = urgent + overdue; if (overdue > 0) { deadlineRiskTextEl.textContent = `${overdue} überfällig, ${urgent} dringend`; } else if (urgent > 0) { deadlineRiskTextEl.textContent = `${urgent} in 24h fällig`; } else { deadlineRiskTextEl.textContent = 'keine Risiken'; } } } } function renderProfileChart() { const container = document.getElementById('profileChart'); const subtitle = document.getElementById('profileChartSubtitle'); if (!container) return; // Count checks per profile const profileCounts = {}; for (let i = 1; i <= 5; i++) { profileCounts[i] = 0; } filteredPosts.forEach(post => { if (Array.isArray(post.checks)) { post.checks.forEach(check => { const profileNum = check.profile_number; if (profileNum >= 1 && profileNum <= 5) { profileCounts[profileNum]++; } }); } }); const maxCount = Math.max(...Object.values(profileCounts), 1); const totalChecks = Object.values(profileCounts).reduce((sum, count) => sum + count, 0); if (subtitle) { subtitle.textContent = `${totalChecks} Teilnahmen gesamt`; } container.innerHTML = Object.entries(profileCounts) .map(([profile, count]) => { const percentage = (count / maxCount) * 100; return `
`; }) .join(''); // Add click handlers for drill-down container.querySelectorAll('.bar-chart-item').forEach(item => { item.addEventListener('click', () => { const profile = item.dataset.profile; currentProfileFilter = profile; document.getElementById('profileFilter').value = profile; applyFilters(); }); }); } function renderProgressChart() { const canvas = document.getElementById('progressChart'); const subtitle = document.getElementById('progressChartSubtitle'); if (!canvas) return; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); // Calculate data const completed = filteredPosts.filter(post => post.is_complete).length; const now = new Date(); const expired = filteredPosts.filter(post => { if (!post.deadline_at) return false; return new Date(post.deadline_at) < now && !post.is_complete; }).length; const active = filteredPosts.length - completed - expired; const data = [ { label: 'Abgeschlossen', value: completed, color: '#38ef7d' }, { label: 'Aktiv', value: active, color: '#f5576c' }, { label: 'Abgelaufen', value: expired, color: '#fee140' } ]; if (subtitle) { const completionRate = filteredPosts.length > 0 ? Math.round((completed / filteredPosts.length) * 100) : 0; subtitle.textContent = `${completionRate}% Erfolgsquote`; } // Draw donut chart const total = data.reduce((sum, item) => sum + item.value, 0); if (total === 0) { ctx.fillStyle = '#65676b'; ctx.font = '16px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Keine Daten verfügbar', canvas.width / 2, canvas.height / 2); return; } const centerX = canvas.width / 2; const centerY = canvas.height / 2; const radius = Math.min(centerX, centerY) - 40; const innerRadius = radius * 0.6; let currentAngle = -Math.PI / 2; data.forEach(item => { const sliceAngle = (item.value / total) * 2 * Math.PI; // Draw slice ctx.beginPath(); ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + sliceAngle); ctx.arc(centerX, centerY, innerRadius, currentAngle + sliceAngle, currentAngle, true); ctx.closePath(); ctx.fillStyle = item.color; ctx.fill(); currentAngle += sliceAngle; }); // Draw center circle (white) ctx.beginPath(); ctx.arc(centerX, centerY, innerRadius, 0, 2 * Math.PI); ctx.fillStyle = 'white'; ctx.fill(); // Draw total in center ctx.fillStyle = '#1c1e21'; ctx.font = 'bold 32px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(total, centerX, centerY - 10); ctx.font = '14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; ctx.fillStyle = '#65676b'; ctx.fillText('Beiträge', centerX, centerY + 15); // Draw legend const legendY = canvas.height - 30; let legendX = 40; const legendSpacing = 120; data.forEach(item => { // Color box ctx.fillStyle = item.color; ctx.fillRect(legendX, legendY, 12, 12); // Label ctx.fillStyle = '#1c1e21'; ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; ctx.textAlign = 'left'; ctx.fillText(`${item.label} (${item.value})`, legendX + 18, legendY + 9); legendX += legendSpacing; }); } function renderPeriodTrendChart() { const canvas = document.getElementById('periodTrendChart'); const subtitle = document.getElementById('trendChartSubtitle'); if (!canvas) { return; } const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); const config = getComparisonConfig(); const currentSeries = buildTrendSeries(config.currentStart, config.bucketCount, config.granularity); const previousSeries = buildTrendSeries(config.previousStart, config.bucketCount, config.granularity); const currentTotal = currentSeries.values.reduce((sum, value) => sum + value, 0); const previousTotal = previousSeries.values.reduce((sum, value) => sum + value, 0); if (subtitle) { subtitle.textContent = `${config.rangeDescription} · ${config.currentLabel}: ${currentTotal} vs. ${config.previousLabel}: ${previousTotal}`; } const maxValue = Math.max(1, ...currentSeries.values, ...previousSeries.values); const padding = 48; const chartWidth = canvas.width - padding * 2; const chartHeight = canvas.height - padding * 2; const stepCount = Math.max(config.bucketCount - 1, 1); // Background grid ctx.strokeStyle = '#e5e7eb'; ctx.lineWidth = 1; const gridLines = 4; ctx.beginPath(); for (let i = 0; i <= gridLines; i++) { const y = padding + (chartHeight / gridLines) * i; ctx.moveTo(padding, y); ctx.lineTo(padding + chartWidth, y); } ctx.stroke(); // Axes ctx.strokeStyle = '#d1d5db'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(padding, padding); ctx.lineTo(padding, padding + chartHeight); ctx.lineTo(padding + chartWidth, padding + chartHeight); ctx.stroke(); const labelInterval = Math.max(1, Math.round(config.bucketCount / 8)); // Y-axis labels ctx.fillStyle = '#6b7280'; ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; ctx.textAlign = 'right'; for (let i = 0; i <= gridLines; i++) { const value = Math.round((maxValue / gridLines) * (gridLines - i)); const y = padding + (chartHeight / gridLines) * i; ctx.fillText(String(value), padding - 8, y + 4); } // X-axis labels ctx.textAlign = 'center'; currentSeries.labels.forEach((label, index) => { if (index % labelInterval !== 0 && index !== currentSeries.labels.length - 1) { return; } const x = padding + (chartWidth / stepCount) * index; ctx.fillText(label, x, padding + chartHeight + 18); }); function drawSeries(values, color) { ctx.beginPath(); values.forEach((value, index) => { const x = padding + (chartWidth / stepCount) * index; const heightRatio = value / maxValue; const y = padding + chartHeight - heightRatio * chartHeight; if (index === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }); ctx.strokeStyle = color; ctx.lineWidth = 2.5; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.stroke(); values.forEach((value, index) => { const x = padding + (chartWidth / stepCount) * index; const y = padding + chartHeight - (value / maxValue) * chartHeight; ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fillStyle = color; ctx.fill(); }); } drawSeries(previousSeries.values, '#9ca3af'); drawSeries(currentSeries.values, '#2563eb'); // Legend const legendY = padding - 22; let legendX = padding; const legendItems = [ { label: config.currentLabel, color: '#2563eb' }, { label: config.previousLabel, color: '#9ca3af' } ]; ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; ctx.textAlign = 'left'; legendItems.forEach(item => { ctx.fillStyle = item.color; ctx.fillRect(legendX, legendY, 12, 12); ctx.fillStyle = '#1f2937'; ctx.fillText(item.label, legendX + 16, legendY + 10); legendX += ctx.measureText(item.label).width + 48; }); } function renderProfileComparisonChart() { const canvas = document.getElementById('profileComparisonChart'); const subtitle = document.getElementById('profileComparisonSubtitle'); if (!canvas) { return; } const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); const config = getComparisonConfig(); const currentCounts = countChecksPerProfile(config.currentStart, config.currentEnd); const previousCounts = countChecksPerProfile(config.previousStart, config.previousEnd); const currentTotal = currentCounts.reduce((sum, value) => sum + value, 0); const previousTotal = previousCounts.reduce((sum, value) => sum + value, 0); if (subtitle) { subtitle.textContent = `${config.currentLabel}: ${currentTotal} · ${config.previousLabel}: ${previousTotal}`; } const maxValue = Math.max(1, ...currentCounts, ...previousCounts); const padding = 56; const chartWidth = canvas.width - padding * 2; const chartHeight = canvas.height - padding * 2; const profileCount = currentCounts.length; const groupWidth = chartWidth / profileCount; const barWidth = groupWidth * 0.32; const groupMargin = (groupWidth - barWidth * 2) / 3; // Axes ctx.strokeStyle = '#d1d5db'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(padding, padding); ctx.lineTo(padding, padding + chartHeight); ctx.lineTo(padding + chartWidth, padding + chartHeight); ctx.stroke(); // Horizontal grid lines ctx.strokeStyle = '#e5e7eb'; ctx.lineWidth = 1; const gridLines = 4; ctx.beginPath(); for (let i = 1; i <= gridLines; i++) { const y = padding + (chartHeight / gridLines) * i; ctx.moveTo(padding, y); ctx.lineTo(padding + chartWidth, y); } ctx.stroke(); // Y-axis labels ctx.fillStyle = '#6b7280'; ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; ctx.textAlign = 'right'; for (let i = 0; i <= gridLines; i++) { const value = Math.round((maxValue / gridLines) * i); const y = padding + chartHeight - (chartHeight / gridLines) * i; ctx.fillText(String(value), padding - 10, y + 4); } // Bars and labels ctx.textAlign = 'center'; ctx.fillStyle = '#1f2937'; ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; for (let index = 0; index < profileCount; index++) { const baseX = padding + index * groupWidth; const currentValue = currentCounts[index]; const previousValue = previousCounts[index]; const currentX = baseX + groupMargin; const previousX = currentX + barWidth + groupMargin; const currentHeight = (currentValue / maxValue) * chartHeight; const previousHeight = (previousValue / maxValue) * chartHeight; ctx.fillStyle = '#2563eb'; ctx.fillRect( currentX, padding + chartHeight - currentHeight, barWidth, currentHeight ); ctx.fillStyle = '#9ca3af'; ctx.fillRect( previousX, padding + chartHeight - previousHeight, barWidth, previousHeight ); // Value labels ctx.fillStyle = '#111827'; ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; ctx.fillText(String(currentValue), currentX + barWidth / 2, padding + chartHeight - currentHeight - 6); ctx.fillText(String(previousValue), previousX + barWidth / 2, padding + chartHeight - previousHeight - 6); // X-axis label ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; ctx.fillStyle = '#1f2937'; ctx.fillText(`Profil ${index + 1}`, baseX + groupWidth / 2, padding + chartHeight + 20); } // Legend const legendY = padding - 22; let legendX = padding; const legendItems = [ { label: config.currentLabel, color: '#2563eb' }, { label: config.previousLabel, color: '#9ca3af' } ]; ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; ctx.textAlign = 'left'; legendItems.forEach(item => { ctx.fillStyle = item.color; ctx.fillRect(legendX, legendY, 12, 12); ctx.fillStyle = '#1f2937'; ctx.fillText(item.label, legendX + 16, legendY + 10); legendX += ctx.measureText(item.label).width + 48; }); } function renderTimelineChart() { const canvas = document.getElementById('timelineChart'); const subtitle = document.getElementById('timelineSubtitle'); if (!canvas) return; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); // Group checks by date const checksByDate = {}; filteredPosts.forEach(post => { if (Array.isArray(post.checks)) { post.checks.forEach(check => { if (check.checked_at) { const date = new Date(check.checked_at); const dateKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; checksByDate[dateKey] = (checksByDate[dateKey] || 0) + 1; } }); } }); // Get date range const dates = Object.keys(checksByDate).sort(); if (dates.length === 0) { ctx.fillStyle = '#65676b'; ctx.font = '16px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Keine Aktivitätsdaten verfügbar', canvas.width / 2, canvas.height / 2); return; } // Fill in missing dates const startDate = new Date(dates[0]); const endDate = new Date(dates[dates.length - 1]); const allDates = []; for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; allDates.push(dateKey); if (!checksByDate[dateKey]) { checksByDate[dateKey] = 0; } } const values = allDates.map(date => checksByDate[date]); const maxValue = Math.max(...values, 1); if (subtitle) { const totalChecks = values.reduce((sum, val) => sum + val, 0); subtitle.textContent = `${totalChecks} Teilnahmen in ${allDates.length} Tagen`; } // Chart dimensions const padding = 40; const chartWidth = canvas.width - 2 * padding; const chartHeight = canvas.height - 2 * padding - 20; const barWidth = chartWidth / allDates.length; // Draw bars ctx.fillStyle = '#1877f2'; allDates.forEach((date, index) => { const value = checksByDate[date]; const barHeight = (value / maxValue) * chartHeight; const x = padding + index * barWidth; const y = padding + chartHeight - barHeight; // Gradient const gradient = ctx.createLinearGradient(x, y, x, padding + chartHeight); gradient.addColorStop(0, '#667eea'); gradient.addColorStop(1, '#764ba2'); ctx.fillStyle = gradient; ctx.fillRect(x, y, Math.max(barWidth - 2, 1), barHeight); }); // Draw axes ctx.strokeStyle = '#e4e6eb'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(padding, padding); ctx.lineTo(padding, padding + chartHeight); ctx.lineTo(padding + chartWidth, padding + chartHeight); ctx.stroke(); // Draw labels (every few dates) ctx.fillStyle = '#65676b'; ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; ctx.textAlign = 'center'; const labelInterval = Math.ceil(allDates.length / 10); allDates.forEach((date, index) => { if (index % labelInterval === 0 || index === allDates.length - 1) { const x = padding + index * barWidth + barWidth / 2; const parts = date.split('-'); const label = `${parts[2]}.${parts[1]}`; ctx.fillText(label, x, padding + chartHeight + 15); } }); // Draw max value label ctx.textAlign = 'right'; ctx.fillText(maxValue, padding - 5, padding + 5); ctx.fillText('0', padding - 5, padding + chartHeight + 5); } function renderTopPerformers() { const container = document.getElementById('topPerformers'); const countBadge = document.getElementById('performersCount'); if (!container) return; // Calculate stats per profile const profileStats = {}; for (let i = 1; i <= 5; i++) { profileStats[i] = { profile: i, checks: 0, postsCreated: 0, avgCompletionTime: 0 }; } filteredPosts.forEach(post => { // Count checks if (Array.isArray(post.checks)) { post.checks.forEach(check => { const profileNum = check.profile_number; if (profileNum >= 1 && profileNum <= 5) { profileStats[profileNum].checks++; } }); } // Count created posts if (post.created_by_profile >= 1 && post.created_by_profile <= 5) { profileStats[post.created_by_profile].postsCreated++; } }); // Calculate score (checks * 2 + posts created) const performers = Object.values(profileStats) .map(stats => ({ ...stats, score: stats.checks * 2 + stats.postsCreated })) .filter(stats => stats.score > 0) .sort((a, b) => b.score - a.score) .slice(0, 5); if (countBadge) { countBadge.textContent = performers.length; } if (performers.length === 0) { container.innerHTML = `