240 lines
6.1 KiB
JavaScript
240 lines
6.1 KiB
JavaScript
/**
|
||
* Chart.js configuration & factory.
|
||
*/
|
||
const ACCENT = '#FFC407';
|
||
const GRID_COLOR = 'rgba(255,255,255,0.06)';
|
||
const TEXT_COLOR = '#888';
|
||
const FONT = 'Montserrat';
|
||
|
||
Chart.defaults.color = TEXT_COLOR;
|
||
Chart.defaults.font.family = FONT;
|
||
Chart.defaults.font.size = 11;
|
||
|
||
const ChartDefs = {
|
||
base() {
|
||
return {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
animation: { duration: 400 },
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
backgroundColor: '#1a1a1a',
|
||
borderColor: '#2e2e2e',
|
||
borderWidth: 1,
|
||
titleColor: '#f0f0f0',
|
||
bodyColor: '#888',
|
||
padding: 10,
|
||
titleFont: { family: FONT, weight: '700', size: 12 },
|
||
bodyFont: { family: FONT, size: 11 },
|
||
},
|
||
},
|
||
};
|
||
},
|
||
|
||
hoursBar(labels, data) {
|
||
return {
|
||
type: 'bar',
|
||
data: {
|
||
labels,
|
||
datasets: [{
|
||
data,
|
||
backgroundColor: ACCENT,
|
||
borderRadius: 6,
|
||
barThickness: 18,
|
||
}],
|
||
},
|
||
options: {
|
||
...this.base(),
|
||
indexAxis: 'y',
|
||
scales: {
|
||
x: {
|
||
grid: { color: GRID_COLOR },
|
||
ticks: { callback: v => v + 'h' },
|
||
},
|
||
y: { grid: { display: false } },
|
||
},
|
||
plugins: {
|
||
...this.base().plugins,
|
||
tooltip: {
|
||
...this.base().plugins.tooltip,
|
||
callbacks: { label: ctx => ` ${ctx.parsed.x.toFixed(1)}h` },
|
||
},
|
||
},
|
||
},
|
||
};
|
||
},
|
||
|
||
donut(labels, data) {
|
||
const colors = [ACCENT, '#fff176', '#e6af00', '#ffd740', '#fff9c4', '#ffb300', '#ffe082', '#ffca28'];
|
||
return {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels,
|
||
datasets: [{
|
||
data,
|
||
backgroundColor: colors.slice(0, data.length),
|
||
borderWidth: 0,
|
||
hoverOffset: 8,
|
||
}],
|
||
},
|
||
options: {
|
||
...this.base(),
|
||
cutout: '68%',
|
||
plugins: {
|
||
...this.base().plugins,
|
||
legend: {
|
||
display: true,
|
||
position: 'right',
|
||
labels: {
|
||
color: TEXT_COLOR,
|
||
font: { family: FONT, size: 11 },
|
||
boxWidth: 10,
|
||
padding: 12,
|
||
},
|
||
},
|
||
tooltip: {
|
||
...this.base().plugins.tooltip,
|
||
callbacks: {
|
||
label: ctx => {
|
||
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
|
||
const pct = total ? ((ctx.parsed / total) * 100).toFixed(1) : 0;
|
||
return ` ${ctx.parsed.toFixed(1)}h (${pct}%)`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
};
|
||
},
|
||
|
||
lineArea(labels, data, label = 'Hours') {
|
||
return {
|
||
type: 'line',
|
||
data: {
|
||
labels,
|
||
datasets: [{
|
||
label,
|
||
data,
|
||
borderColor: ACCENT,
|
||
backgroundColor: 'rgba(255,196,7,0.12)',
|
||
fill: true,
|
||
tension: 0.35,
|
||
pointRadius: 3,
|
||
pointBackgroundColor: ACCENT,
|
||
borderWidth: 2,
|
||
}],
|
||
},
|
||
options: {
|
||
...this.base(),
|
||
scales: {
|
||
x: { grid: { color: GRID_COLOR } },
|
||
y: {
|
||
grid: { color: GRID_COLOR },
|
||
ticks: { callback: v => v + 'h' },
|
||
},
|
||
},
|
||
plugins: {
|
||
...this.base().plugins,
|
||
tooltip: {
|
||
...this.base().plugins.tooltip,
|
||
callbacks: { label: ctx => ` ${ctx.parsed.y.toFixed(1)}h` },
|
||
},
|
||
},
|
||
},
|
||
};
|
||
},
|
||
|
||
columnBar(labels, data) {
|
||
return {
|
||
type: 'bar',
|
||
data: {
|
||
labels,
|
||
datasets: [{
|
||
data,
|
||
backgroundColor: ACCENT,
|
||
borderRadius: 6,
|
||
}],
|
||
},
|
||
options: {
|
||
...this.base(),
|
||
scales: {
|
||
x: { grid: { display: false } },
|
||
y: {
|
||
grid: { color: GRID_COLOR },
|
||
ticks: { callback: v => v + 'h' },
|
||
},
|
||
},
|
||
plugins: {
|
||
...this.base().plugins,
|
||
tooltip: {
|
||
...this.base().plugins.tooltip,
|
||
callbacks: { label: ctx => ` ${ctx.parsed.y.toFixed(1)}h` },
|
||
},
|
||
},
|
||
},
|
||
};
|
||
},
|
||
|
||
timeline(sessions) {
|
||
// Gantt-like bars: each session = one horizontal bar
|
||
// Filter out sessions with missing/invalid timestamps
|
||
sessions = sessions.filter(s => {
|
||
const st = new Date(s.start_at).getTime();
|
||
const en = new Date(s.end_at).getTime();
|
||
return st > 0 && en > 0 && !isNaN(st) && !isNaN(en) && en >= st;
|
||
});
|
||
const labels = sessions.map(s => s.project_name.substring(0, 20));
|
||
const starts = sessions.map(s => new Date(s.start_at).getTime());
|
||
const ends = sessions.map(s => new Date(s.end_at).getTime());
|
||
const data = sessions.map((s, i) => [starts[i], ends[i]]);
|
||
|
||
return {
|
||
type: 'bar',
|
||
data: {
|
||
labels,
|
||
datasets: [{
|
||
data,
|
||
backgroundColor: ACCENT + 'cc',
|
||
borderSkipped: false,
|
||
borderRadius: 4,
|
||
barThickness: 16,
|
||
}],
|
||
},
|
||
options: {
|
||
...this.base(),
|
||
indexAxis: 'y',
|
||
scales: {
|
||
x: {
|
||
type: 'time',
|
||
time: { unit: 'hour', displayFormats: { hour: 'HH:mm' } },
|
||
grid: { color: GRID_COLOR },
|
||
},
|
||
y: { grid: { display: false } },
|
||
},
|
||
plugins: {
|
||
...this.base().plugins,
|
||
tooltip: {
|
||
...this.base().plugins.tooltip,
|
||
callbacks: {
|
||
label: ctx => {
|
||
const s = sessions[ctx.dataIndex];
|
||
const from = new Date(s.start_at).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' });
|
||
const to = new Date(s.end_at).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' });
|
||
return ` ${from} – ${to} (${s.active_hours.toFixed(1)}h)`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
};
|
||
},
|
||
};
|
||
|
||
function makeChart(canvasId, config) {
|
||
const canvas = document.getElementById(canvasId);
|
||
if (!canvas) return null;
|
||
const existing = Chart.getChart(canvas);
|
||
if (existing) existing.destroy();
|
||
return new Chart(canvas, config);
|
||
}
|