1585 lines
49 KiB
JavaScript
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();
|
|
})();
|