Files
TimeTracker/app/static/keyboard-shortcuts-advanced.js
Dries Peeters b1973ca49a feat: Add Quick Wins feature set - activity tracking, templates, and user preferences
This commit introduces several high-impact features to improve user experience
and productivity:

New Features:
- Activity Logging: Comprehensive audit trail tracking user actions across the
  system with Activity model, including IP address and user agent tracking
- Time Entry Templates: Reusable templates for frequently logged activities with
  usage tracking and quick-start functionality
- Saved Filters: Save and reuse common search/filter combinations across
  different views (projects, tasks, reports)
- User Preferences: Enhanced user settings including email notifications,
  timezone, date/time formats, week start day, and theme preferences
- Excel Export: Generate formatted Excel exports for time entries and reports
  with styling and proper formatting
- Email Notifications: Complete email system for task assignments, overdue
  invoices, comments, and weekly summaries with HTML templates
- Scheduled Tasks: Background task scheduler for periodic operations

Models Added:
- Activity: Tracks all user actions with detailed context and metadata
- TimeEntryTemplate: Stores reusable time entry configurations
- SavedFilter: Manages user-saved filter configurations

Routes Added:
- user.py: User profile and settings management
- saved_filters.py: CRUD operations for saved filters
- time_entry_templates.py: Template management endpoints

UI Enhancements:
- Bulk actions widget component
- Keyboard shortcuts help modal with advanced shortcuts
- Save filter widget component
- Email notification templates
- User profile and settings pages
- Saved filters management interface
- Time entry templates interface

Database Changes:
- Migration 022: Creates activities and time_entry_templates tables
- Adds user preference columns (notifications, timezone, date/time formats)
- Proper indexes for query optimization

Backend Updates:
- Enhanced keyboard shortcuts system (commands.js, keyboard-shortcuts-advanced.js)
- Updated projects, reports, and tasks routes with activity logging
- Safe database commit utilities integration
- Event tracking for analytics

Dependencies:
- Added openpyxl for Excel generation
- Added Flask-Mail dependencies
- Updated requirements.txt

All new features include proper error handling, activity logging integration,
and maintain existing functionality while adding new capabilities.
2025-10-23 09:05:07 +02:00

617 lines
20 KiB
JavaScript

/**
* Advanced Keyboard Shortcuts System
* Customizable, context-aware keyboard shortcuts
*/
class KeyboardShortcutManager {
constructor() {
this.shortcuts = new Map();
this.contexts = new Map();
this.currentContext = 'global';
this.recording = false;
this.customShortcuts = this.loadCustomShortcuts();
this.initDefaultShortcuts();
this.init();
}
init() {
document.addEventListener('keydown', (e) => this.handleKeyPress(e));
this.detectContext();
// Listen for context changes
document.addEventListener('focusin', () => this.detectContext());
window.addEventListener('popstate', () => this.detectContext());
}
/**
* Register a keyboard shortcut
*/
register(key, callback, options = {}) {
const {
context = 'global',
description = '',
category = 'General',
preventDefault = true,
stopPropagation = false
} = options;
const shortcutKey = this.normalizeKey(key);
if (!this.shortcuts.has(context)) {
this.shortcuts.set(context, new Map());
}
this.shortcuts.get(context).set(shortcutKey, {
callback,
description,
category,
preventDefault,
stopPropagation,
originalKey: key
});
}
/**
* Initialize default shortcuts
*/
initDefaultShortcuts() {
// Global shortcuts
this.register('Ctrl+K', () => this.openCommandPalette(), {
description: 'Open command palette',
category: 'Navigation'
});
this.register('Ctrl+/', () => this.toggleSearch(), {
description: 'Toggle search',
category: 'Navigation'
});
this.register('Ctrl+B', () => this.toggleSidebar(), {
description: 'Toggle sidebar',
category: 'Navigation'
});
this.register('Ctrl+D', () => this.toggleDarkMode(), {
description: 'Toggle dark mode',
category: 'Appearance'
});
this.register('Shift+/', () => this.showShortcutsPanel(), {
description: 'Show keyboard shortcuts',
category: 'Help',
preventDefault: true
});
// Navigation shortcuts
this.register('g d', () => this.navigateTo('/main/dashboard'), {
description: 'Go to Dashboard',
category: 'Navigation'
});
this.register('g p', () => this.navigateTo('/projects/'), {
description: 'Go to Projects',
category: 'Navigation'
});
this.register('g t', () => this.navigateTo('/tasks/'), {
description: 'Go to Tasks',
category: 'Navigation'
});
this.register('g r', () => this.navigateTo('/reports/'), {
description: 'Go to Reports',
category: 'Navigation'
});
this.register('g i', () => this.navigateTo('/invoices/'), {
description: 'Go to Invoices',
category: 'Navigation'
});
// Creation shortcuts
this.register('c p', () => this.createProject(), {
description: 'Create new project',
category: 'Actions'
});
this.register('c t', () => this.createTask(), {
description: 'Create new task',
category: 'Actions'
});
this.register('c c', () => this.createClient(), {
description: 'Create new client',
category: 'Actions'
});
// Timer shortcuts
this.register('t s', () => this.startTimer(), {
description: 'Start timer',
category: 'Timer'
});
this.register('t p', () => this.pauseTimer(), {
description: 'Pause timer',
category: 'Timer'
});
this.register('t l', () => this.logTime(), {
description: 'Log time manually',
category: 'Timer'
});
// Table shortcuts (context-specific)
this.register('Ctrl+A', () => this.selectAllRows(), {
context: 'table',
description: 'Select all rows',
category: 'Table'
});
this.register('Delete', () => this.deleteSelected(), {
context: 'table',
description: 'Delete selected rows',
category: 'Table'
});
this.register('Escape', () => this.clearSelection(), {
context: 'table',
description: 'Clear selection',
category: 'Table'
});
// Modal shortcuts
this.register('Escape', () => this.closeModal(), {
context: 'modal',
description: 'Close modal',
category: 'Modal'
});
this.register('Enter', () => this.submitForm(), {
context: 'modal',
description: 'Submit form',
category: 'Modal',
preventDefault: false
});
// Editing shortcuts
this.register('Ctrl+S', () => this.saveForm(), {
context: 'editing',
description: 'Save changes',
category: 'Editing'
});
this.register('Ctrl+Z', () => this.undo(), {
description: 'Undo',
category: 'Editing'
});
this.register('Ctrl+Shift+Z', () => this.redo(), {
description: 'Redo',
category: 'Editing'
});
// Quick actions
this.register('Shift+?', () => this.showQuickActions(), {
description: 'Show quick actions',
category: 'Actions'
});
}
/**
* Handle key press
*/
handleKeyPress(e) {
// When palette is open, do not trigger a second open; let commands.js handle focus
const palette = document.getElementById('commandPaletteModal');
const paletteOpen = palette && !palette.classList.contains('hidden');
// Ignore if typing in input/textarea except for allowed global combos
if (this.isTyping(e)) {
// Allow Ctrl+/ to focus search even when typing
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
e.preventDefault();
this.toggleSearch();
}
// Allow Ctrl+K to open/focus palette even when typing
else if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
if (paletteOpen) {
// Just refocus input when already open
const inputExisting = document.getElementById('commandPaletteInput');
if (inputExisting) setTimeout(() => inputExisting.focus(), 50);
} else {
this.openCommandPalette();
}
}
return;
}
const key = this.getKeyCombo(e);
const normalizedKey = this.normalizeKey(key);
// Debug logging (can be removed in production)
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
console.log('Keyboard shortcut detected:', {
key: e.key,
combo: key,
normalized: normalizedKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey
});
}
// Prevent duplicate open when palette already visible (Ctrl+K, ?, etc.)
if (paletteOpen) {
// If user hits palette keys while open, just refocus and exit
if ((e.ctrlKey || e.metaKey) && (e.key.toLowerCase() === 'k' || e.key === '?')) {
e.preventDefault();
const inputExisting = document.getElementById('commandPaletteInput');
if (inputExisting) setTimeout(() => inputExisting.focus(), 50);
return;
}
}
// Check custom shortcuts first
if (this.customShortcuts.has(normalizedKey)) {
const customAction = this.customShortcuts.get(normalizedKey);
this.executeAction(customAction);
e.preventDefault();
return;
}
// Check context-specific shortcuts
const contextShortcuts = this.shortcuts.get(this.currentContext);
if (contextShortcuts && contextShortcuts.has(normalizedKey)) {
const shortcut = contextShortcuts.get(normalizedKey);
if (shortcut.preventDefault) e.preventDefault();
if (shortcut.stopPropagation) e.stopPropagation();
shortcut.callback(e);
return;
}
// Check global shortcuts
const globalShortcuts = this.shortcuts.get('global');
if (globalShortcuts && globalShortcuts.has(normalizedKey)) {
const shortcut = globalShortcuts.get(normalizedKey);
if (shortcut.preventDefault) e.preventDefault();
if (shortcut.stopPropagation) e.stopPropagation();
shortcut.callback(e);
}
}
/**
* Get key combination from event
*/
getKeyCombo(e) {
const parts = [];
if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
let key = e.key;
if (key === ' ') key = 'Space';
// Don't uppercase special characters like /, ?, etc.
if (key.length === 1 && key.match(/[a-zA-Z0-9]/)) {
key = key.toUpperCase();
}
parts.push(key);
return parts.join('+');
}
/**
* Normalize key for consistent matching
*/
normalizeKey(key) {
return key.replace(/\s+/g, ' ').toLowerCase();
}
/**
* Check if user is typing
*/
isTyping(e) {
const target = e.target;
const tagName = target.tagName.toLowerCase();
const isInput = tagName === 'input' || tagName === 'textarea' || target.isContentEditable;
// Don't block anything if not in an input
if (!isInput) {
return false;
}
// Allow Escape in search inputs to close/clear
if (target.type === 'search' && e.key === 'Escape') {
return false;
}
// Allow Ctrl+/ and Cmd+/ even in inputs for search
if (e.key === '/' && (e.ctrlKey || e.metaKey)) {
return false;
}
// Allow Ctrl+K and Cmd+K even in inputs for command palette
if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {
return false;
}
// Allow Shift+? for shortcuts panel
if (e.key === '?' && e.shiftKey) {
return false;
}
// Block all other keys when typing
return true;
}
/**
* Detect current context
*/
detectContext() {
// Check for modal
if (document.querySelector('.modal:not(.hidden), [role="dialog"]:not(.hidden)')) {
this.currentContext = 'modal';
return;
}
// Check for table
if (document.activeElement.closest('table[data-enhanced]')) {
this.currentContext = 'table';
return;
}
// Check for editing
if (document.activeElement.closest('form[data-auto-save]')) {
this.currentContext = 'editing';
return;
}
this.currentContext = 'global';
}
/**
* Show shortcuts panel
*/
showShortcutsPanel() {
const panel = document.createElement('div');
panel.className = 'fixed inset-0 z-50 overflow-y-auto';
panel.innerHTML = `
<div class="flex items-center justify-center min-h-screen px-4">
<div class="fixed inset-0 bg-black/50" onclick="this.parentElement.parentElement.remove()"></div>
<div class="relative bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-hidden">
<div class="p-6 border-b border-border-light dark:border-border-dark flex items-center justify-between">
<h2 class="text-2xl font-bold">Keyboard Shortcuts</h2>
<button onclick="this.closest('.fixed').remove()" class="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-6 overflow-y-auto max-h-[60vh]">
${this.renderShortcutsList()}
</div>
<div class="p-4 border-t border-border-light dark:border-border-dark flex justify-between items-center bg-gray-50 dark:bg-gray-800">
<button onclick="shortcutManager.customizeShortcuts()" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">
<i class="fas fa-cog mr-2"></i>Customize
</button>
<button onclick="this.closest('.fixed').remove()" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600">
Close
</button>
</div>
</div>
</div>
`;
document.body.appendChild(panel);
}
/**
* Render shortcuts list
*/
renderShortcutsList() {
const categories = {};
// Organize by category
this.shortcuts.forEach((contextShortcuts) => {
contextShortcuts.forEach((shortcut, key) => {
if (!categories[shortcut.category]) {
categories[shortcut.category] = [];
}
categories[shortcut.category].push({
key: shortcut.originalKey,
description: shortcut.description
});
});
});
let html = '';
Object.keys(categories).sort().forEach(category => {
html += `
<div class="mb-6">
<h3 class="text-lg font-semibold mb-3 text-primary">${category}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
${categories[category].map(s => `
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded">
<span class="text-sm">${s.description}</span>
<kbd class="px-2 py-1 text-xs font-mono bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded">${s.key}</kbd>
</div>
`).join('')}
</div>
</div>
`;
});
return html;
}
/**
* Load custom shortcuts from localStorage
*/
loadCustomShortcuts() {
try {
const saved = localStorage.getItem('custom_shortcuts');
return saved ? new Map(JSON.parse(saved)) : new Map();
} catch {
return new Map();
}
}
/**
* Save custom shortcuts
*/
saveCustomShortcuts() {
localStorage.setItem('custom_shortcuts', JSON.stringify([...this.customShortcuts]));
}
// Action implementations
openCommandPalette() {
const modal = document.getElementById('commandPaletteModal');
if (modal) {
// If already open, just focus
if (!modal.classList.contains('hidden')) {
const inputExisting = document.getElementById('commandPaletteInput');
if (inputExisting) setTimeout(() => inputExisting.focus(), 50);
return;
}
modal.classList.remove('hidden');
const input = document.getElementById('commandPaletteInput');
if (input) setTimeout(() => input.focus(), 100);
}
}
toggleSearch() {
// Prefer the main header search input
let searchInput = document.getElementById('search');
if (!searchInput) {
searchInput = document.querySelector('form.navbar-search input[type="search"], input[type="search"], input[name="q"], .search-enhanced input');
}
if (searchInput) {
// Ensure parent sections are visible (e.g., if search is in a collapsed container)
try { searchInput.closest('.hidden')?.classList.remove('hidden'); } catch(_) {}
searchInput.focus();
if (typeof searchInput.select === 'function') searchInput.select();
}
}
toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const btn = document.getElementById('sidebarCollapseBtn');
if (btn) btn.click();
}
toggleDarkMode() {
const btn = document.getElementById('theme-toggle');
if (btn) btn.click();
}
navigateTo(url) {
window.location.href = url;
}
createProject() {
const btn = document.querySelector('a[href*="create_project"]');
if (btn) btn.click();
else this.navigateTo('/projects/create');
}
createTask() {
const btn = document.querySelector('a[href*="create_task"]');
if (btn) btn.click();
else this.navigateTo('/tasks/create');
}
createClient() {
this.navigateTo('/clients/create');
}
startTimer() {
const btn = document.querySelector('#openStartTimer, button[onclick*="startTimer"]');
if (btn) btn.click();
}
pauseTimer() {
const btn = document.querySelector('button[onclick*="pauseTimer"], button[onclick*="stopTimer"]');
if (btn) btn.click();
}
logTime() {
this.navigateTo('/timer/manual_entry');
}
selectAllRows() {
const checkbox = document.querySelector('.select-all-checkbox');
if (checkbox) {
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change'));
}
}
deleteSelected() {
if (window.bulkDelete) {
window.bulkDelete();
}
}
clearSelection() {
if (window.clearSelection) {
window.clearSelection();
}
}
closeModal() {
const modal = document.querySelector('.modal:not(.hidden), [role="dialog"]:not(.hidden)');
if (modal) {
const closeBtn = modal.querySelector('[data-close], .close, button[onclick*="close"]');
if (closeBtn) closeBtn.click();
else modal.classList.add('hidden');
}
}
submitForm() {
const form = document.querySelector('form:not(.filter-form)');
if (form && document.activeElement.tagName !== 'TEXTAREA') {
form.submit();
}
}
saveForm() {
const form = document.querySelector('form[data-auto-save]');
if (form) {
// Trigger auto-save
form.dispatchEvent(new Event('submit'));
}
}
undo() {
if (window.undoManager) {
window.undoManager.undo();
}
}
redo() {
if (window.undoManager) {
window.undoManager.redo();
}
}
showQuickActions() {
if (window.quickActionsMenu) {
window.quickActionsMenu.toggle();
}
}
executeAction(action) {
// Execute custom action
console.log('Executing custom action:', action);
}
customizeShortcuts() {
window.toastManager?.info('Shortcut customization coming soon!');
}
}
// Initialize
window.shortcutManager = new KeyboardShortcutManager();
console.log('Advanced keyboard shortcuts loaded. Press ? to see all shortcuts.');