Files
TimeTracker/app/static/error-handling-enhanced.js
T
Dries Peeters 33ad9a0c26 feat: Complete offline sync, form auto-save, browser fallbacks, and smart notifications
- Offline sync: Implement task and project sync functionality
  * Add syncTasks() and syncProjects() methods with full CRUD support
  * Implement saveTaskOffline(), saveProjectOffline() helper methods
  * Add createTaskOffline() and createProjectOffline() public API methods
  * Update markAsSynced() to handle tasks and projects correctly
  * Support both create and update operations for offline entities

- Enhanced UI: Complete form auto-save initialization
  * Add comprehensive error handling and validation
  * Implement storage quota error handling with fallback
  * Improve indicator with error states and better visual feedback
  * Add beforeunload handler to save state on page unload
  * Prevent duplicate initialization with proper tracking
  * Add CSRF token handling and form method detection
  * Use passive event listeners for better performance

- Error handling: Implement browser fallbacks for older browsers
  * Add polyfillFetch() using XMLHttpRequest for fetch API fallback
  * Implement polyfillLocalStorage() with in-memory storage
  * Add sessionStorage fallback support
  * Initialize fallbacks early in error handler setup

- Smart notifications: Complete check methods with error handling
  * Enhance checkIdleTime() with duplicate prevention and better event handling
  * Improve checkDeadlines() with validation, rate limiting, and error handling
  * Enhance checkDailySummary() with once-per-day sending and better formatting
  * Add comprehensive error handling to startBackgroundTasks()
  * Implement proper network error handling and response validation

All implementations include proper error handling, edge case coverage,
performance optimizations, and browser compatibility considerations.
2025-12-29 12:32:09 +01:00

906 lines
30 KiB
JavaScript

/**
* Enhanced Error Handling System
* User-friendly messages, retry buttons, offline queue, graceful degradation, and recovery options
*/
class EnhancedErrorHandler {
constructor() {
this.retryQueue = [];
this.offlineQueue = [];
this.isOnline = navigator.onLine;
this.retryAttempts = new Map();
this.maxRetries = 3;
// Track recent errors to prevent duplicates
this.recentErrors = new Map(); // message -> timestamp
this.errorDeduplicationWindow = 60000; // 1 minute - don't show same error twice within this window
this.init();
}
init() {
// Setup feature fallbacks first (before other initialization)
this.setupFeatureFallbacks();
// Setup network status monitoring
this.setupNetworkMonitoring();
// Setup fetch interceptors
this.setupFetchInterceptor();
// Setup global error handlers
this.setupGlobalErrorHandlers();
// Setup offline queue processor
this.setupOfflineQueue();
// Setup graceful degradation
this.setupGracefulDegradation();
}
/**
* Network Status Monitoring
*/
setupNetworkMonitoring() {
window.addEventListener('online', () => {
this.isOnline = true;
this.handleOnline();
});
window.addEventListener('offline', () => {
this.isOnline = false;
this.handleOffline();
});
// Periodic online check - every 30 seconds
// This helps detect cases where browser thinks it's online but server is unreachable
// The browser's online/offline events handle most cases, this is a fallback
setInterval(() => {
this.checkOnlineStatus();
}, 30000); // Check every 30 seconds instead of 5
}
checkOnlineStatus() {
fetch('/api/health', { method: 'GET', cache: 'no-cache' })
.then((response) => {
if (response.ok && !this.isOnline) {
this.isOnline = true;
this.handleOnline();
}
})
.catch(() => {
if (this.isOnline) {
this.isOnline = false;
this.handleOffline();
}
});
}
handleOnline() {
// Show online indicator
this.showOnlineIndicator();
// Process offline queue
this.processOfflineQueue();
// Retry failed operations
this.retryFailedOperations();
}
handleOffline() {
// Show offline indicator
this.showOfflineIndicator();
}
showOnlineIndicator() {
if (window.toastManager) {
window.toastManager.success(
'Connection restored. Processing queued operations...',
'Back Online',
3000
);
}
}
showOfflineIndicator() {
// Create persistent offline indicator
if (document.getElementById('offline-indicator')) return;
const indicator = document.createElement('div');
indicator.id = 'offline-indicator';
indicator.className = 'offline-indicator';
indicator.innerHTML = `
<div class="offline-indicator-content">
<i class="fas fa-wifi-slash"></i>
<span>You're offline. Some features may be limited.</span>
<span class="offline-queue-count" id="offline-queue-count"></span>
</div>
`;
document.body.appendChild(indicator);
// Add styles
this.addOfflineIndicatorStyles();
}
addOfflineIndicatorStyles() {
if (document.getElementById('offline-indicator-styles')) return;
const style = document.createElement('style');
style.id = 'offline-indicator-styles';
style.textContent = `
.offline-indicator {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #f59e0b;
color: white;
padding: 12px 16px;
text-align: center;
z-index: 9999;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
animation: slideDown 0.3s ease-out;
}
.dark .offline-indicator {
background: #d97706;
}
.offline-indicator-content {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 0.875rem;
}
.offline-queue-count {
margin-left: 8px;
font-weight: 600;
}
@keyframes slideDown {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
.error-retry-container {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.error-retry-btn {
padding: 6px 12px;
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.error-retry-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.error-retry-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message-friendly {
margin-bottom: 8px;
}
.error-recovery-options {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.dark .error-recovery-options {
border-top-color: rgba(255, 255, 255, 0.1);
}
.error-recovery-btn {
display: inline-block;
margin: 4px 8px 4px 0;
padding: 6px 12px;
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.dark .error-recovery-btn {
background: #374151;
color: #e5e7eb;
border-color: #4b5563;
}
.error-recovery-btn:hover {
background: #e5e7eb;
}
.dark .error-recovery-btn:hover {
background: #4b5563;
}
`;
document.head.appendChild(style);
}
/**
* Fetch Interceptor for Error Handling
*/
setupFetchInterceptor() {
const originalFetch = window.fetch;
window.fetch = async (...args) => {
const [url, options = {}] = args;
try {
const response = await originalFetch(...args);
// Handle non-ok responses
if (!response.ok) {
return this.handleFetchError(response, url, options);
}
return response;
} catch (error) {
// Network error or other fetch error
return this.handleFetchException(error, url, options);
}
};
}
async handleFetchError(response, url, options) {
// Clone the response before reading it, so the caller can still read it
const clonedResponse = response.clone();
const errorData = await clonedResponse.json().catch(() => ({ error: 'Unknown error' }));
const userFriendlyMessage = this.getUserFriendlyMessage(response.status, errorData);
// Show error notification with retry option
const errorId = this.showErrorWithRetry(userFriendlyMessage, response.status, () => {
return this.retryFetch(url, options);
});
// Queue for offline processing if offline
if (!this.isOnline) {
this.queueForOffline(url, options, errorId);
}
// Return original response so caller can handle it
return response;
}
async handleFetchException(error, url, options) {
// Network error
if (!this.isOnline) {
this.queueForOffline(url, options);
return new Response(JSON.stringify({ error: 'Offline' }), {
status: 0,
statusText: 'Offline'
});
}
const userFriendlyMessage = this.getUserFriendlyMessage(0, error);
const errorId = this.showErrorWithRetry(userFriendlyMessage, 0, () => {
return this.retryFetch(url, options);
});
return new Response(JSON.stringify({ error: userFriendlyMessage }), {
status: 0,
statusText: 'Network Error'
});
}
async retryFetch(url, options) {
const retryKey = `${url}-${JSON.stringify(options)}`;
const attempts = this.retryAttempts.get(retryKey) || 0;
if (attempts >= this.maxRetries) {
this.showError(
'Maximum retry attempts reached. Please try again later or contact support.',
'Retry Failed'
);
return null;
}
this.retryAttempts.set(retryKey, attempts + 1);
try {
const response = await fetch(url, options);
if (response.ok) {
this.retryAttempts.delete(retryKey);
return response;
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (attempts < this.maxRetries - 1) {
// Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempts) * 1000));
return this.retryFetch(url, options);
}
throw error;
}
}
/**
* User-Friendly Error Messages
*/
getUserFriendlyMessage(status, errorData) {
const errorMessage = errorData?.error || errorData?.message || 'An error occurred';
const messages = {
0: 'Unable to connect to the server. Please check your internet connection.',
400: 'Invalid request. Please check your input and try again.',
401: 'You need to log in to access this feature.',
403: 'You don\'t have permission to perform this action.',
404: 'The requested resource was not found.',
409: 'This action conflicts with existing data. Please refresh and try again.',
422: 'Validation error. Please check your input.',
429: 'Too many requests. Please wait a moment and try again.',
500: 'A server error occurred. Our team has been notified.',
502: 'The server is temporarily unavailable. Please try again later.',
503: 'Service temporarily unavailable. Please try again in a few moments.',
504: 'Request timeout. Please try again.'
};
// Try to get specific message from server
if (typeof errorData === 'object' && errorData.message) {
return errorData.message;
}
// Fallback to status-based message
return messages[status] || `An error occurred (${status}). ${errorMessage}`;
}
/**
* Show Error with Retry Button
*/
showErrorWithRetry(message, status, retryCallback) {
const recoveryOptions = this.getRecoveryOptions(status);
// Create error toast with retry
if (window.toastManager) {
const toastId = window.toastManager.error(message, 'Error', 0);
// Find toast element by ID
const toastElement = window.toastManager.container.querySelector(
`[data-toast-id="${toastId}"]`
) || Array.from(window.toastManager.container.children).find(
el => el.getAttribute('data-toast-id') === String(toastId)
);
if (toastElement) {
const retryContainer = document.createElement('div');
retryContainer.className = 'error-retry-container';
const retryBtn = document.createElement('button');
retryBtn.className = 'error-retry-btn';
retryBtn.textContent = 'Retry';
retryBtn.onclick = async () => {
retryBtn.disabled = true;
retryBtn.textContent = 'Retrying...';
try {
await retryCallback();
window.toastManager.dismiss(toastId);
window.toastManager.success('Operation completed successfully', 'Success');
} catch (error) {
retryBtn.disabled = false;
retryBtn.textContent = 'Retry';
window.toastManager.error(
this.getUserFriendlyMessage(0, error),
'Retry Failed'
);
}
};
retryContainer.appendChild(retryBtn);
// Add recovery options if available
if (recoveryOptions.length > 0) {
const recoveryDiv = document.createElement('div');
recoveryDiv.className = 'error-recovery-options';
recoveryOptions.forEach(option => {
const btn = document.createElement('button');
btn.className = 'error-recovery-btn';
btn.textContent = option.label;
btn.onclick = option.action;
recoveryDiv.appendChild(btn);
});
retryContainer.appendChild(recoveryDiv);
}
toastElement.appendChild(retryContainer);
}
return toastId;
}
// Fallback to console
console.error('Error:', message);
return null;
}
showError(message, title = 'Error') {
// Check for duplicates before showing
if (this.isDuplicateError(message)) {
console.warn('Duplicate error suppressed:', message);
return;
}
if (window.toastManager) {
window.toastManager.error(message, title);
} else {
console.error(title + ':', message);
}
}
/**
* Check if an error message was recently shown (deduplication)
*/
isDuplicateError(message) {
const now = Date.now();
const lastShown = this.recentErrors.get(message);
if (lastShown && (now - lastShown) < this.errorDeduplicationWindow) {
return true; // This error was shown recently
}
// Update the timestamp for this error
this.recentErrors.set(message, now);
// Clean up old entries (older than deduplication window)
for (const [msg, timestamp] of this.recentErrors.entries()) {
if (now - timestamp >= this.errorDeduplicationWindow) {
this.recentErrors.delete(msg);
}
}
return false;
}
/**
* Recovery Options
*/
getRecoveryOptions(status) {
const options = [];
switch (status) {
case 401:
options.push({
label: 'Go to Login',
action: () => {
window.location.href = '/auth/login';
}
});
break;
case 403:
options.push({
label: 'Go to Dashboard',
action: () => {
window.location.href = '/main/dashboard';
}
});
break;
case 404:
options.push({
label: 'Go to Dashboard',
action: () => {
window.location.href = '/main/dashboard';
}
});
options.push({
label: 'Go Back',
action: () => {
window.history.back();
}
});
break;
case 500:
case 502:
case 503:
case 504:
options.push({
label: 'Refresh Page',
action: () => {
window.location.reload();
}
});
break;
}
return options;
}
/**
* Offline Queue Management
*/
queueForOffline(url, options, errorId = null) {
const queueItem = {
url,
options,
errorId,
timestamp: Date.now(),
retries: 0
};
this.offlineQueue.push(queueItem);
this.updateOfflineQueueIndicator();
// Store in localStorage for persistence
this.saveOfflineQueue();
}
saveOfflineQueue() {
try {
localStorage.setItem('offline_queue', JSON.stringify(this.offlineQueue));
} catch (e) {
console.warn('Failed to save offline queue:', e);
}
}
loadOfflineQueue() {
try {
const stored = localStorage.getItem('offline_queue');
if (stored) {
this.offlineQueue = JSON.parse(stored);
this.updateOfflineQueueIndicator();
}
} catch (e) {
console.warn('Failed to load offline queue:', e);
}
}
async processOfflineQueue() {
if (this.offlineQueue.length === 0) return;
const queue = [...this.offlineQueue];
this.offlineQueue = [];
for (const item of queue) {
try {
const response = await fetch(item.url, item.options);
if (response.ok && item.errorId) {
window.toastManager?.dismiss(item.errorId);
}
} catch (error) {
// Re-queue if still failing
this.offlineQueue.push(item);
}
}
this.updateOfflineQueueIndicator();
this.saveOfflineQueue();
}
updateOfflineQueueIndicator() {
const countElement = document.getElementById('offline-queue-count');
if (countElement) {
const count = this.offlineQueue.length;
if (count > 0) {
countElement.textContent = `(${count} pending)`;
countElement.style.display = 'inline';
} else {
countElement.style.display = 'none';
}
}
}
setupOfflineQueue() {
// Load existing queue on init
this.loadOfflineQueue();
// Process queue when online
if (this.isOnline && this.offlineQueue.length > 0) {
this.processOfflineQueue();
}
}
/**
* Global Error Handlers
*/
setupGlobalErrorHandlers() {
// JavaScript errors
window.addEventListener('error', (event) => {
this.handleJavaScriptError(event.error, event.message, event.filename, event.lineno);
});
// Unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
this.handleUnhandledRejection(event.reason);
});
}
handleJavaScriptError(error, message, filename, lineno) {
if (this.shouldIgnoreFrontendNoise(error, message)) {
return;
}
const userFriendlyMessage = 'An unexpected error occurred. Please refresh the page or contact support if the problem persists.';
// Check if we've shown this error recently
if (this.isDuplicateError(userFriendlyMessage)) {
// Log to console but don't show duplicate toast
console.error('JavaScript Error (duplicate suppressed):', {
error,
message,
filename,
lineno
});
return;
}
this.showError(userFriendlyMessage, 'Application Error');
// Log to console for debugging
console.error('JavaScript Error:', {
error,
message,
filename,
lineno
});
}
handleUnhandledRejection(reason) {
if (this.shouldIgnoreFrontendNoise(reason, reason?.message)) {
return;
}
const userFriendlyMessage = 'An operation failed unexpectedly. Please try again or contact support if the problem persists.';
// Check if we've shown this error recently
if (this.isDuplicateError(userFriendlyMessage)) {
// Log to console but don't show duplicate toast
console.error('Unhandled Rejection (duplicate suppressed):', reason);
return;
}
this.showError(userFriendlyMessage, 'Operation Failed');
// Log to console for debugging
console.error('Unhandled Rejection:', reason);
}
/**
* Graceful Degradation
*/
setupGracefulDegradation() {
// Check for required features
this.checkRequiredFeatures();
// Setup feature fallbacks
this.setupFeatureFallbacks();
}
checkRequiredFeatures() {
// Only check for truly critical features required for app functionality
// serviceWorker is optional (PWA enhancement) and only works over HTTPS
const features = {
'localStorage': typeof Storage !== 'undefined',
'fetch': typeof fetch !== 'undefined'
};
const missing = Object.entries(features)
.filter(([_, available]) => !available)
.map(([name]) => name);
if (missing.length > 0) {
console.warn('Missing features:', missing);
this.showError(
`Some features may not work properly: ${missing.join(', ')}. Please update your browser.`,
'Browser Compatibility'
);
}
// Log serviceWorker availability for debugging (but don't show warning)
if (!('serviceWorker' in navigator)) {
const isSecureContext = window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1';
if (!isSecureContext) {
console.debug('ServiceWorker not available: requires HTTPS (or localhost)');
} else {
console.debug('ServiceWorker not available: browser does not support it');
}
}
}
setupFeatureFallbacks() {
// Fallback for fetch if not available
if (typeof fetch === 'undefined') {
console.warn('Fetch API not available, using XMLHttpRequest fallback');
this.polyfillFetch();
}
// Fallback for localStorage
if (typeof Storage === 'undefined' || typeof localStorage === 'undefined') {
console.warn('LocalStorage not available, using memory storage');
this.polyfillLocalStorage();
}
}
polyfillFetch() {
// Simple fetch polyfill using XMLHttpRequest
window.fetch = function(url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const method = options.method || 'GET';
const headers = options.headers || {};
xhr.open(method, url, true);
// Set headers
Object.keys(headers).forEach(key => {
xhr.setRequestHeader(key, headers[key]);
});
// Handle response
xhr.onload = function() {
const response = {
ok: xhr.status >= 200 && xhr.status < 300,
status: xhr.status,
statusText: xhr.statusText,
headers: {
get: function(name) {
return xhr.getResponseHeader(name);
}
},
text: function() {
return Promise.resolve(xhr.responseText);
},
json: function() {
try {
return Promise.resolve(JSON.parse(xhr.responseText));
} catch (e) {
return Promise.reject(new Error('Invalid JSON response'));
}
},
blob: function() {
return Promise.resolve(new Blob([xhr.response]));
},
arrayBuffer: function() {
return Promise.resolve(xhr.response);
}
};
if (xhr.status >= 200 && xhr.status < 300) {
resolve(response);
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
}
};
xhr.onerror = function() {
reject(new Error('Network request failed'));
};
xhr.ontimeout = function() {
reject(new Error('Request timeout'));
};
// Set timeout if specified
if (options.timeout) {
xhr.timeout = options.timeout;
}
// Send request
if (options.body) {
if (typeof options.body === 'string') {
xhr.send(options.body);
} else if (options.body instanceof FormData) {
xhr.send(options.body);
} else {
xhr.send(JSON.stringify(options.body));
}
} else {
xhr.send();
}
});
};
}
polyfillLocalStorage() {
// In-memory storage fallback
const memoryStorage = {};
window.localStorage = {
getItem: function(key) {
return memoryStorage[key] || null;
},
setItem: function(key, value) {
try {
memoryStorage[key] = String(value);
// Dispatch storage event for compatibility
window.dispatchEvent(new Event('storage'));
} catch (e) {
console.warn('Memory storage setItem failed:', e);
}
},
removeItem: function(key) {
try {
delete memoryStorage[key];
window.dispatchEvent(new Event('storage'));
} catch (e) {
console.warn('Memory storage removeItem failed:', e);
}
},
clear: function() {
try {
Object.keys(memoryStorage).forEach(key => {
delete memoryStorage[key];
});
window.dispatchEvent(new Event('storage'));
} catch (e) {
console.warn('Memory storage clear failed:', e);
}
},
get length() {
return Object.keys(memoryStorage).length;
},
key: function(index) {
const keys = Object.keys(memoryStorage);
return keys[index] || null;
}
};
// Also polyfill sessionStorage with same in-memory implementation
if (typeof sessionStorage === 'undefined') {
window.sessionStorage = Object.create(window.localStorage);
}
}
retryFailedOperations() {
// Retry operations from retry queue
const queue = [...this.retryQueue];
this.retryQueue = [];
queue.forEach(operation => {
try {
operation();
} catch (error) {
console.error('Failed to retry operation:', error);
}
});
}
shouldIgnoreFrontendNoise(error, message) {
const normalizedMessage = String(message || error?.message || '').toLowerCase();
// Known benign ResizeObserver warning triggered by various UI libraries/browsers
if (normalizedMessage.includes('resizeobserver loop limit exceeded') ||
normalizedMessage.includes('resizeobserver loop completed with undelivered notifications')) {
console.debug('Ignored benign ResizeObserver warning:', message || error);
return true;
}
return false;
}
}
// Initialize enhanced error handler
window.enhancedErrorHandler = new EnhancedErrorHandler();
// Remove offline indicator when online
document.addEventListener('DOMContentLoaded', () => {
if (navigator.onLine) {
const indicator = document.getElementById('offline-indicator');
if (indicator) {
indicator.remove();
}
}
});