mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-05 19:20:21 -06:00
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.
477 lines
15 KiB
JavaScript
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);
|
|
}
|
|
|