import '../styles/main.scss'; import { Chart, BarController, BarElement, CategoryScale, LinearScale, Tooltip, DoughnutController, ArcElement, } from 'chart.js'; import { createTranslator, esc } from './utils.js'; Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, DoughnutController, ArcElement); const t = createTranslator('Statistics'); const months = window.Statistics.monthsShort; const weekdays = window.Statistics.weekdaysShort; const DONUT_COLORS = [ '#4a90d9', '#f0a500', '#2d9e60', '#c83232', '#8b5cf6', '#e85d75', '#00b4d8', '#ff8c42', ]; const REST_COLOR = '#d0d8e0'; const THRESHOLD = 0.05; let chart = null; let donutCharts = { clients: null, projects: null, services: null }; let cachedData = null; function formatLabel(key, groupBy) { if (groupBy === 'month') { const [year, month] = key.split('-'); return months[parseInt(month, 10) - 1] + ' ' + year.slice(2); } if (groupBy === 'week') { const week = key.split('-')[1]; return t('weekShort') + ' ' + parseInt(week, 10); } const d = new Date(key + 'T00:00:00'); const dayOfWeek = weekdays[(d.getDay() + 6) % 7]; const day = d.getDate(); const mon = months[d.getMonth()]; return dayOfWeek + ' ' + day + '. ' + mon; } function formatHours(value) { const h = Math.floor(value); const m = Math.round((value - h) * 60); return h + ':' + String(m).padStart(2, '0'); } function formatCurrency(value) { return value.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'; } function getWeekNumber(dateStr) { const d = new Date(dateStr + 'T00:00:00'); const dayNum = d.getDay() || 7; d.setDate(d.getDate() + 4 - dayNum); const yearStart = new Date(d.getFullYear(), 0, 1); return Math.ceil((((d - yearStart) / 86400000) + 1) / 7); } function getBrandColor() { return getComputedStyle(document.documentElement) .getPropertyValue('--color-primary').trim() || '#4a90d9'; } function renderChart(data, metric) { const canvas = document.getElementById('stats-chart'); if (!canvas) return; if (chart) { chart.destroy(); chart = null; } const isRevenue = metric === 'revenue'; const brandColor = getBrandColor(); const greyColor = 'rgba(170, 184, 198, 0.55)'; const labels = data.labels.map(l => formatLabel(l, data.groupBy)); const dataset1 = isRevenue ? data.billableRevenue : data.billable; const dataset2 = isRevenue ? data.nonBillableRevenue : data.nonBillable; let weekBands = []; if (data.groupBy === 'day') { let currentWeek = null; let bandStart = null; let bandIdx = 0; data.labels.forEach((key, i) => { const wk = getWeekNumber(key); if (wk !== currentWeek) { if (currentWeek !== null && bandIdx % 2 === 1) { weekBands.push([bandStart, i - 1]); } currentWeek = wk; bandStart = i; bandIdx++; } }); if (bandIdx % 2 === 1) { weekBands.push([bandStart, data.labels.length - 1]); } } chart = new Chart(canvas, { type: 'bar', data: { labels, datasets: [ { label: t('billable'), data: dataset1, backgroundColor: brandColor, borderRadius: 3, borderSkipped: false, }, { label: t('nonBillable'), data: dataset2, backgroundColor: greyColor, borderRadius: 3, borderSkipped: false, }, ], }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 600, easing: 'easeOutQuart', }, interaction: { mode: 'index', intersect: false, }, plugins: { legend: { display: false }, tooltip: { backgroundColor: '#1a2a3a', titleFont: { weight: '600' }, bodyFont: { size: 13 }, padding: 10, cornerRadius: 6, callbacks: { label(ctx) { if (isRevenue) { return ctx.dataset.label + ': ' + formatCurrency(ctx.parsed.y); } return ctx.dataset.label + ': ' + formatHours(ctx.parsed.y) + ' ' + t('hours'); }, }, }, }, scales: { x: { grid: { display: false }, ticks: { font: { size: 11 }, maxRotation: data.groupBy === 'day' ? 45 : 0, autoSkip: true, autoSkipPadding: 8, }, }, y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.06)' }, ticks: { font: { size: 11 }, callback(value) { if (isRevenue) return formatCurrency(value); return formatHours(value); }, }, }, }, }, plugins: data.groupBy === 'day' ? [{ id: 'weekBands', beforeDraw(chartInstance) { const { ctx, chartArea, scales } = chartInstance; if (!chartArea || weekBands.length === 0) return; ctx.save(); ctx.fillStyle = 'rgba(0, 0, 0, 0.03)'; weekBands.forEach(([start, end]) => { const x1 = scales.x.getPixelForValue(start) - (scales.x.getPixelForValue(1) - scales.x.getPixelForValue(0)) / 2; const x2 = scales.x.getPixelForValue(end) + (scales.x.getPixelForValue(1) - scales.x.getPixelForValue(0)) / 2; ctx.fillRect(x1, chartArea.top, x2 - x1, chartArea.bottom - chartArea.top); }); ctx.restore(); }, }] : [], }); } function applyThreshold(items, metric) { const valueKey = metric === 'revenue' ? 'revenue' : 'hours'; const total = items.reduce((sum, item) => sum + item[valueKey], 0); if (total === 0) return { labels: [], values: [], colors: [] }; const visible = []; let restValue = 0; let colorIdx = 0; for (const item of items) { const val = item[valueKey]; if (val / total >= THRESHOLD) { visible.push({ name: item.name || t('noService'), value: val, color: DONUT_COLORS[colorIdx % DONUT_COLORS.length] }); colorIdx++; } else { restValue += val; } } if (restValue > 0) { visible.push({ name: t('rest'), value: restValue, color: REST_COLOR }); } return { labels: visible.map(v => v.name), values: visible.map(v => v.value), colors: visible.map(v => v.color), }; } function renderDonut(canvasId, legendId, chartKey, items, metric) { const canvas = document.getElementById(canvasId); const legendEl = document.getElementById(legendId); if (!canvas) return; if (donutCharts[chartKey]) { donutCharts[chartKey].destroy(); donutCharts[chartKey] = null; } const { labels, values, colors } = applyThreshold(items, metric); const isRevenue = metric === 'revenue'; const total = values.reduce((s, v) => s + v, 0); if (total === 0) { if (legendEl) legendEl.innerHTML = ''; return; } donutCharts[chartKey] = new Chart(canvas, { type: 'doughnut', data: { labels, datasets: [{ data: values, backgroundColor: colors, borderWidth: 2, borderColor: '#ffffff', }], }, options: { responsive: true, maintainAspectRatio: false, cutout: '62%', animation: { duration: 600, easing: 'easeOutQuart' }, plugins: { legend: { display: false }, tooltip: { backgroundColor: '#1a2a3a', bodyFont: { size: 13 }, padding: 10, cornerRadius: 6, callbacks: { label(ctx) { const val = ctx.parsed; const pct = ((val / total) * 100).toFixed(1) + '%'; const formatted = isRevenue ? formatCurrency(val) : formatHours(val); return ctx.label + ': ' + formatted + ' (' + pct + ')'; }, }, }, }, }, }); if (legendEl) { legendEl.innerHTML = labels.map((label, i) => { const pct = ((values[i] / total) * 100).toFixed(1); const formatted = isRevenue ? formatCurrency(values[i]) : formatHours(values[i]); return `
${esc(label)} ${formatted} (${pct}%)
`; }).join(''); } } function renderDonuts(data, metric) { if (!data?.distribution) return; const d = data.distribution; renderDonut('donut-clients', 'donut-legend-clients', 'clients', d.clients, metric); renderDonut('donut-projects', 'donut-legend-projects', 'projects', d.projects, metric); renderDonut('donut-services', 'donut-legend-services', 'services', d.services, metric); } async function loadAndRender(range, metric, userId) { const wrap = document.getElementById('stats-chart-wrap'); if (!wrap) return; let url = '/api/statistics?range=' + encodeURIComponent(range); if (userId) { url += '&userId=' + encodeURIComponent(userId); } try { const res = await fetch(url); if (!res.ok) throw new Error(res.statusText); cachedData = await res.json(); renderChart(cachedData, metric); renderDonuts(cachedData, metric); } catch { wrap.innerHTML = '

' + t('errorLoad') + '

'; } } function getSelectedUserId() { const el = document.getElementById('stats-user-select'); return el ? el.value : ''; } document.addEventListener('DOMContentLoaded', () => { const rangeSelect = document.getElementById('stats-range-select'); const metricSelect = document.getElementById('stats-metric-select'); const userSelect = document.getElementById('stats-user-select'); if (!rangeSelect || !metricSelect) return; loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId()); rangeSelect.addEventListener('change', () => { loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId()); }); if (userSelect) { userSelect.addEventListener('change', () => { loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId()); }); } metricSelect.addEventListener('change', () => { if (cachedData) { renderChart(cachedData, metricSelect.value); renderDonuts(cachedData, metricSelect.value); } }); });