import '../styles/main.scss'; import { Chart, BarController, BarElement, CategoryScale, LinearScale, Tooltip, } from 'chart.js'; import { createTranslator } from './utils.js'; Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip); const t = createTranslator('Statistics'); const months = window.Statistics.monthsShort; const weekdays = window.Statistics.weekdaysShort; let chart = 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(); }, }] : [], }); } 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); } 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); } }); });