mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-04-27 07:50:29 -05:00
33ad9a0c26
- 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.
906 lines
30 KiB
JavaScript
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();
|
|
}
|
|
}
|
|
});
|
|
|