Files
PostTracker/web/dashboard.js
2025-12-21 14:21:55 +01:00

1585 lines
49 KiB
JavaScript

(() => {
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 `
<div class="bar-chart-item" data-profile="${profile}" style="cursor: pointer;">
<div class="bar-chart-item__label">Profil ${profile}</div>
<div class="bar-chart-item__bar-container">
<div class="bar-chart-item__bar" style="width: ${percentage}%">
${count > 0 ? count : ''}
</div>
</div>
<div class="bar-chart-item__value">${count}</div>
</div>
`;
})
.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 = `
<div class="empty-state">
<div class="empty-state-icon">🏆</div>
<div class="empty-state-text">Noch keine Aktivitäten</div>
</div>
`;
return;
}
container.innerHTML = performers.map((performer, index) => {
let rankClass = '';
let medal = '';
if (index === 0) {
rankClass = 'performer-item--gold';
medal = '🥇';
} else if (index === 1) {
rankClass = 'performer-item--silver';
medal = '🥈';
} else if (index === 2) {
rankClass = 'performer-item--bronze';
medal = '🥉';
}
return `
<div class="performer-item ${rankClass}">
<div class="performer-item__rank">${index + 1}</div>
<div class="performer-item__avatar">${medal || '👤'}</div>
<div class="performer-item__content">
<div class="performer-item__name">Profil ${performer.profile}</div>
<div class="performer-item__stats">
${performer.checks} Teilnahmen · ${performer.postsCreated} Beiträge erstellt
</div>
</div>
<div class="performer-item__badge">${performer.score} Punkte</div>
</div>
`;
}).join('');
}
function renderRecentActivity() {
const container = document.getElementById('recentActivity');
const countBadge = document.getElementById('activityCount');
if (!container) return;
// Collect all checks with post info
const activities = [];
filteredPosts.forEach(post => {
if (Array.isArray(post.checks)) {
post.checks.forEach(check => {
activities.push({
type: 'check',
postTitle: getPostDisplayTitle(post),
profileNumber: check.profile_number,
timestamp: check.checked_at,
postId: post.id
});
});
}
});
// Sort by timestamp (newest first)
activities.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
// Take top 10
const recentActivities = activities.slice(0, 10);
if (countBadge) {
countBadge.textContent = recentActivities.length;
}
if (recentActivities.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<div class="empty-state-text">Noch keine Aktivitäten</div>
</div>
`;
return;
}
container.innerHTML = recentActivities.map(activity => {
const date = new Date(activity.timestamp);
const timeAgo = formatTimeAgo(date);
return `
<div class="activity-item">
<div class="activity-item__icon">✓</div>
<div class="activity-item__content">
<div class="activity-item__text">
<span class="activity-item__profile">Profil ${activity.profileNumber}</span>
hat "${activity.postTitle}" bestätigt
</div>
<div class="activity-item__time">${timeAgo}</div>
</div>
</div>
`;
}).join('');
}
function renderUpcomingDeadlines() {
const container = document.getElementById('upcomingDeadlines');
const countBadge = document.getElementById('deadlinesCount');
if (!container) return;
const now = new Date();
// Get posts with deadlines that haven't been completed
const postsWithDeadlines = filteredPosts
.filter(post => post.deadline_at && !post.is_complete)
.map(post => {
const deadline = new Date(post.deadline_at);
const hoursUntil = (deadline - now) / (1000 * 60 * 60);
return {
...post,
deadline,
hoursUntil
};
})
.filter(post => post.hoursUntil > 0) // Only future deadlines
.sort((a, b) => a.deadline - b.deadline)
.slice(0, 8);
if (countBadge) {
countBadge.textContent = postsWithDeadlines.length;
}
if (postsWithDeadlines.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🎉</div>
<div class="empty-state-text">Keine anstehenden Deadlines</div>
</div>
`;
return;
}
container.innerHTML = postsWithDeadlines.map(post => {
const timeUntil = formatTimeUntil(post.deadline);
let urgencyClass = '';
if (post.hoursUntil <= 24) {
urgencyClass = 'deadline-item--danger';
} else if (post.hoursUntil <= 72) {
urgencyClass = 'deadline-item--warning';
}
return `
<div class="deadline-item ${urgencyClass}">
<div class="deadline-item__content">
<div class="deadline-item__title">${getPostDisplayTitle(post)}</div>
<div class="deadline-item__progress">${post.checked_count}/${post.target_count} Bestätigungen</div>
</div>
<div class="deadline-item__time">${timeUntil}</div>
</div>
`;
}).join('');
}
// Removed - metrics now handled in renderKeyMetrics()
function formatTimeAgo(date) {
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 60) return 'gerade eben';
if (seconds < 3600) return `vor ${Math.floor(seconds / 60)} Min.`;
if (seconds < 86400) return `vor ${Math.floor(seconds / 3600)} Std.`;
if (seconds < 604800) return `vor ${Math.floor(seconds / 86400)} Tagen`;
return date.toLocaleDateString('de-DE');
}
function formatTimeUntil(date) {
const now = new Date();
const hours = Math.floor((date - now) / (1000 * 60 * 60));
if (hours < 1) {
const minutes = Math.floor((date - now) / (1000 * 60));
return `in ${minutes} Min.`;
}
if (hours < 24) return `in ${hours} Std.`;
const days = Math.floor(hours / 24);
return `in ${days} ${days === 1 ? 'Tag' : 'Tagen'}`;
}
function renderSuccessAnalysis() {
renderWeeklySuccessComparison();
renderMonthlySuccessComparison();
renderYearlySuccessComparison();
}
function renderWeeklySuccessComparison() {
const container = document.getElementById('weeklySuccessComparison');
if (!container) return;
const now = new Date();
const thisWeekStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000);
const thisWeekData = calculateSuccessStats(posts, thisWeekStart, now);
const lastWeekData = calculateSuccessStats(posts, lastWeekStart, thisWeekStart);
container.innerHTML = renderSuccessComparisonContent(thisWeekData, lastWeekData, 'Diese Woche', 'Letzte Woche');
}
function renderMonthlySuccessComparison() {
const container = document.getElementById('monthlySuccessComparison');
if (!container) return;
const now = new Date();
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59);
const thisMonthData = calculateSuccessStats(posts, thisMonthStart, now);
const lastMonthData = calculateSuccessStats(posts, lastMonthStart, lastMonthEnd);
container.innerHTML = renderSuccessComparisonContent(thisMonthData, lastMonthData, 'Dieser Monat', 'Letzter Monat');
}
function renderYearlySuccessComparison() {
const container = document.getElementById('yearlySuccessComparison');
if (!container) return;
const now = new Date();
const thisYearStart = new Date(now.getFullYear(), 0, 1);
const lastYearStart = new Date(now.getFullYear() - 1, 0, 1);
const lastYearEnd = new Date(now.getFullYear() - 1, 11, 31, 23, 59, 59);
const thisYearData = calculateSuccessStats(posts, thisYearStart, now);
const lastYearData = calculateSuccessStats(posts, lastYearStart, lastYearEnd);
container.innerHTML = renderSuccessComparisonContent(thisYearData, lastYearData, 'Dieses Jahr', 'Letztes Jahr');
}
function calculateSuccessStats(allPosts, startDate, endDate) {
const periodPosts = allPosts.filter(post => {
if (!post.created_at) return false;
const postDate = new Date(post.created_at);
return postDate >= startDate && postDate < endDate;
});
const successful = periodPosts.filter(post => post.is_successful).length;
const completed = periodPosts.filter(post => post.is_complete).length;
const total = periodPosts.length;
return {
successful,
completed,
total,
successRate: total > 0 ? Math.round((successful / total) * 100) : 0,
completionRate: total > 0 ? Math.round((completed / total) * 100) : 0
};
}
function renderSuccessComparisonContent(currentData, previousData, currentLabel, previousLabel) {
const successChange = calculateChange(currentData.successful, previousData.successful);
const rateChange = calculateChange(currentData.successRate, previousData.successRate);
const maxSuccessful = Math.max(currentData.successful, previousData.successful, 1);
const maxTotal = Math.max(currentData.total, previousData.total, 1);
return `
<div class="comparison-item">
<div class="comparison-item__header">
<div class="comparison-item__label">Erfolgreich markiert</div>
${renderChangeIndicator(successChange)}
</div>
<div class="comparison-item__value">${currentData.successful}</div>
<div class="comparison-item__bar">
<div class="comparison-item__bar-fill comparison-item__bar-fill--success"
style="width: ${(currentData.successful / maxSuccessful) * 100}%"></div>
</div>
<div class="comparison-item__subtext">${previousLabel}: ${previousData.successful}</div>
</div>
<div class="comparison-divider"></div>
<div class="comparison-item">
<div class="comparison-item__header">
<div class="comparison-item__label">Erfolgsrate</div>
${renderChangeIndicator(rateChange)}
</div>
<div class="comparison-item__value">${currentData.successRate}%</div>
<div class="comparison-item__bar">
<div class="comparison-item__bar-fill comparison-item__bar-fill--success"
style="width: ${currentData.successRate}%"></div>
</div>
<div class="comparison-item__subtext">${currentData.successful} von ${currentData.total} Beiträgen</div>
</div>
<div class="comparison-divider"></div>
<div class="comparison-item">
<div class="comparison-item__header">
<div class="comparison-item__label">Abschlussrate</div>
</div>
<div class="comparison-item__value">${currentData.completionRate}%</div>
<div class="comparison-item__bar">
<div class="comparison-item__bar-fill comparison-item__bar-fill--current"
style="width: ${currentData.completionRate}%"></div>
</div>
<div class="comparison-item__subtext">${currentData.completed} von ${currentData.total} abgeschlossen</div>
</div>
`;
}
function renderComparisons() {
renderWeeklyComparison();
renderMonthlyComparison();
renderYearlyComparison();
}
function renderWeeklyComparison() {
const container = document.getElementById('weeklyComparison');
if (!container) return;
const now = new Date();
const thisWeekStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000);
const thisWeekData = calculatePeriodStats(posts, thisWeekStart, now);
const lastWeekData = calculatePeriodStats(posts, lastWeekStart, thisWeekStart);
container.innerHTML = renderComparisonContent(thisWeekData, lastWeekData, 'Diese Woche', 'Letzte Woche');
}
function renderMonthlyComparison() {
const container = document.getElementById('monthlyComparison');
if (!container) return;
const now = new Date();
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59);
const thisMonthData = calculatePeriodStats(posts, thisMonthStart, now);
const lastMonthData = calculatePeriodStats(posts, lastMonthStart, lastMonthEnd);
container.innerHTML = renderComparisonContent(thisMonthData, lastMonthData, 'Dieser Monat', 'Letzter Monat');
}
function renderYearlyComparison() {
const container = document.getElementById('yearlyComparison');
if (!container) return;
const now = new Date();
const thisYearStart = new Date(now.getFullYear(), 0, 1);
const lastYearStart = new Date(now.getFullYear() - 1, 0, 1);
const lastYearEnd = new Date(now.getFullYear() - 1, 11, 31, 23, 59, 59);
const thisYearData = calculatePeriodStats(posts, thisYearStart, now);
const lastYearData = calculatePeriodStats(posts, lastYearStart, lastYearEnd);
container.innerHTML = renderComparisonContent(thisYearData, lastYearData, 'Dieses Jahr', 'Letztes Jahr');
}
function calculatePeriodStats(allPosts, startDate, endDate) {
const periodPosts = allPosts.filter(post => {
if (!post.created_at) return false;
const postDate = new Date(post.created_at);
return postDate >= startDate && postDate < endDate;
});
let totalChecks = 0;
let completedPosts = 0;
periodPosts.forEach(post => {
if (post.is_complete) {
completedPosts++;
}
if (Array.isArray(post.checks)) {
post.checks.forEach(check => {
if (check.checked_at) {
const checkDate = new Date(check.checked_at);
if (checkDate >= startDate && checkDate < endDate) {
totalChecks++;
}
}
});
}
});
return {
posts: periodPosts.length,
checks: totalChecks,
completed: completedPosts
};
}
function renderComparisonContent(currentData, previousData, currentLabel, previousLabel) {
const postsChange = calculateChange(currentData.posts, previousData.posts);
const checksChange = calculateChange(currentData.checks, previousData.checks);
const completedChange = calculateChange(currentData.completed, previousData.completed);
const maxPosts = Math.max(currentData.posts, previousData.posts, 1);
const maxChecks = Math.max(currentData.checks, previousData.checks, 1);
const maxCompleted = Math.max(currentData.completed, previousData.completed, 1);
return `
<div class="comparison-item">
<div class="comparison-item__header">
<div class="comparison-item__label">Neue Beiträge</div>
${renderChangeIndicator(postsChange)}
</div>
<div class="comparison-item__value">${currentData.posts}</div>
<div class="comparison-item__bar">
<div class="comparison-item__bar-fill comparison-item__bar-fill--current"
style="width: ${(currentData.posts / maxPosts) * 100}%"></div>
</div>
<div class="comparison-item__subtext">${previousLabel}: ${previousData.posts}</div>
</div>
<div class="comparison-divider"></div>
<div class="comparison-item">
<div class="comparison-item__header">
<div class="comparison-item__label">Teilnahmen</div>
${renderChangeIndicator(checksChange)}
</div>
<div class="comparison-item__value">${currentData.checks}</div>
<div class="comparison-item__bar">
<div class="comparison-item__bar-fill comparison-item__bar-fill--current"
style="width: ${(currentData.checks / maxChecks) * 100}%"></div>
</div>
<div class="comparison-item__subtext">${previousLabel}: ${previousData.checks}</div>
</div>
<div class="comparison-divider"></div>
<div class="comparison-item">
<div class="comparison-item__header">
<div class="comparison-item__label">Abgeschlossen</div>
${renderChangeIndicator(completedChange)}
</div>
<div class="comparison-item__value">${currentData.completed}</div>
<div class="comparison-item__bar">
<div class="comparison-item__bar-fill comparison-item__bar-fill--current"
style="width: ${(currentData.completed / maxCompleted) * 100}%"></div>
</div>
<div class="comparison-item__subtext">${previousLabel}: ${previousData.completed}</div>
</div>
`;
}
function calculateChange(current, previous) {
if (previous === 0) {
return current > 0 ? 100 : 0;
}
return Math.round(((current - previous) / previous) * 100);
}
function renderChangeIndicator(changePercent) {
if (changePercent > 0) {
return `<div class="comparison-item__change comparison-item__change--up">↑ ${changePercent}%</div>`;
} else if (changePercent < 0) {
return `<div class="comparison-item__change comparison-item__change--down">↓ ${Math.abs(changePercent)}%</div>`;
} else {
return `<div class="comparison-item__change comparison-item__change--neutral">→ 0%</div>`;
}
}
// Event listeners
document.getElementById('timeFilter')?.addEventListener('change', (e) => {
currentTimeFilter = e.target.value;
applyFilters();
});
document.getElementById('profileFilter')?.addEventListener('change', (e) => {
currentProfileFilter = e.target.value;
applyFilters();
});
document.getElementById('refreshBtn')?.addEventListener('click', () => {
fetchPosts();
});
// Initialize
fetchPosts();
})();