Files
TimeTracker/app/static/charts.js
Dries Peeters f5c3c3f59f fix: resolve keyboard shortcut conflicts and notification errors
Fixed multiple issues with keyboard shortcuts and browser notifications:

Keyboard Shortcuts:
- Fixed Ctrl+/ not working to focus search input
- Resolved conflict between three event handlers (base.html, commands.js, keyboard-shortcuts-advanced.js)
- Changed inline handler from Ctrl+K to Ctrl+/ to avoid command palette conflict
- Updated search bar UI badge to display Ctrl+/ instead of Ctrl+K
- Removed conflicting ? key handler from commands.js (now uses Shift+? for shortcuts panel)
- Improved key detection to properly handle special characters like / and ?
- Added debug logging for troubleshooting keyboard events

Final keyboard mapping:
- Ctrl+K: Open Command Palette
- Ctrl+/: Focus Search Input
- Shift+?: Show All Keyboard Shortcuts
- Esc: Close Modals/Panels

Notification System:
- Fixed "right-hand side of 'in' should be an object" error in smart-notifications.js
- Changed notification permission request to follow browser security policies
- Permission now checked silently on load, only requested on user interaction
- Added "Enable Notifications" banner in notification center panel
- Fixed service worker sync check to properly verify registration object

Browser Compatibility:
- All fixes respect browser security policies for notification permissions
- Graceful degradation when service worker features unavailable
- Works correctly on Chrome, Firefox, Safari, and Edge

Files modified:
- app/static/enhanced-search.js
- app/static/keyboard-shortcuts-advanced.js
- app/static/smart-notifications.js
- app/templates/base.html
- app/static/commands.js

Closes issues with keyboard shortcuts not responding and browser console errors.
2025-10-20 13:00:39 +02:00

477 lines
15 KiB
JavaScript

/**
* Chart Utilities for TimeTracker
* Easy-to-use chart creation with Chart.js
*/
class ChartManager {
constructor() {
this.charts = new Map();
this.defaultColors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1'
];
}
/**
* Create a time series line chart
*/
createTimeSeriesChart(canvasId, data, options = {}) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: data.datasets.map((dataset, index) => ({
label: dataset.label,
data: dataset.data,
borderColor: dataset.color || this.defaultColors[index],
backgroundColor: this.hexToRgba(dataset.color || this.defaultColors[index], 0.1),
borderWidth: 2,
fill: dataset.fill !== undefined ? dataset.fill : true,
tension: 0.4,
pointRadius: 3,
pointHoverRadius: 5
}))
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: data.datasets.length > 1,
position: 'top',
labels: {
usePointStyle: true,
padding: 15
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
displayColors: true
},
...options.plugins
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
},
ticks: {
callback: function(value) {
return options.yAxisFormat ? options.yAxisFormat(value) : value;
}
}
},
x: {
grid: {
display: false
}
}
},
...options
}
});
this.charts.set(canvasId, chart);
return chart;
}
/**
* Create a bar chart for comparisons
*/
createBarChart(canvasId, data, options = {}) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
const chart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.labels,
datasets: data.datasets.map((dataset, index) => ({
label: dataset.label,
data: dataset.data,
backgroundColor: dataset.color || this.defaultColors[index],
borderRadius: 6,
barPercentage: 0.7
}))
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: data.datasets.length > 1,
position: 'top',
labels: {
usePointStyle: true,
padding: 15
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
},
ticks: {
callback: function(value) {
return options.yAxisFormat ? options.yAxisFormat(value) : value;
}
}
},
x: {
grid: {
display: false
}
}
},
...options
}
});
this.charts.set(canvasId, chart);
return chart;
}
/**
* Create a doughnut/pie chart for distributions
*/
createDoughnutChart(canvasId, data, options = {}) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
const chart = new Chart(ctx, {
type: options.type || 'doughnut',
data: {
labels: data.labels,
datasets: [{
data: data.values,
backgroundColor: data.colors || this.defaultColors,
borderWidth: 2,
borderColor: '#ffffff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
usePointStyle: true,
padding: 15,
generateLabels: function(chart) {
const data = chart.data;
if (data.labels.length && data.datasets.length) {
return data.labels.map((label, i) => {
const value = data.datasets[0].data[i];
const total = data.datasets[0].data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return {
text: `${label} (${percentage}%)`,
fillStyle: data.datasets[0].backgroundColor[i],
hidden: false,
index: i
};
});
}
return [];
}
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value} (${percentage}%)`;
}
}
}
},
...options
}
});
this.charts.set(canvasId, chart);
return chart;
}
/**
* Create a progress/gauge chart
*/
createProgressChart(canvasId, value, max, options = {}) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
const percentage = (value / max) * 100;
const remaining = 100 - percentage;
const chart = new Chart(ctx, {
type: 'doughnut',
data: {
datasets: [{
data: [percentage, remaining],
backgroundColor: [
options.color || '#3b82f6',
'rgba(0, 0, 0, 0.05)'
],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '75%',
rotation: -90,
circumference: 180,
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
}
},
plugins: [{
id: 'centerText',
afterDraw: function(chart) {
const ctx = chart.ctx;
const centerX = (chart.chartArea.left + chart.chartArea.right) / 2;
const centerY = chart.chartArea.bottom;
ctx.save();
ctx.font = 'bold 24px sans-serif';
ctx.fillStyle = options.color || '#3b82f6';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${percentage.toFixed(1)}%`, centerX, centerY - 20);
if (options.label) {
ctx.font = '12px sans-serif';
ctx.fillStyle = '#6b7280';
ctx.fillText(options.label, centerX, centerY);
}
ctx.restore();
}
}]
});
this.charts.set(canvasId, chart);
return chart;
}
/**
* Create a sparkline (mini line chart)
*/
createSparkline(canvasId, data, options = {}) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: data.map((_, i) => i),
datasets: [{
data: data,
borderColor: options.color || '#3b82f6',
borderWidth: 2,
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
enabled: options.tooltip !== false,
mode: 'index',
intersect: false,
displayColors: false
}
},
scales: {
y: {
display: false
},
x: {
display: false
}
},
interaction: {
mode: 'index',
intersect: false
}
}
});
this.charts.set(canvasId, chart);
return chart;
}
/**
* Create a stacked area chart
*/
createStackedAreaChart(canvasId, data, options = {}) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: data.datasets.map((dataset, index) => ({
label: dataset.label,
data: dataset.data,
borderColor: dataset.color || this.defaultColors[index],
backgroundColor: this.hexToRgba(dataset.color || this.defaultColors[index], 0.5),
borderWidth: 2,
fill: true,
tension: 0.4
}))
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: true,
position: 'top'
},
tooltip: {
mode: 'index',
intersect: false
}
},
scales: {
y: {
stacked: true,
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
stacked: true,
grid: {
display: false
}
}
},
...options
}
});
this.charts.set(canvasId, chart);
return chart;
}
/**
* Update chart data
*/
updateChart(canvasId, newData) {
const chart = this.charts.get(canvasId);
if (!chart) return;
if (newData.labels) {
chart.data.labels = newData.labels;
}
if (newData.datasets) {
chart.data.datasets = newData.datasets;
}
if (newData.values) {
chart.data.datasets[0].data = newData.values;
}
chart.update();
}
/**
* Destroy a chart
*/
destroyChart(canvasId) {
const chart = this.charts.get(canvasId);
if (chart) {
chart.destroy();
this.charts.delete(canvasId);
}
}
/**
* Utility: Convert hex color to rgba
*/
hexToRgba(hex, alpha = 1) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/**
* Get chart instance
*/
getChart(canvasId) {
return this.charts.get(canvasId);
}
/**
* Export chart as image
*/
exportChart(canvasId, filename = 'chart.png') {
const chart = this.charts.get(canvasId);
if (!chart) return;
const url = chart.toBase64Image();
const link = document.createElement('a');
link.download = filename;
link.href = url;
link.click();
}
}
// Initialize global chart manager
window.chartManager = new ChartManager();
// Utility function to format hours
function formatHours(hours) {
return `${hours.toFixed(1)}h`;
}
// Utility function to format currency
function formatCurrency(value, currency = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency
}).format(value);
}