mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 21:00:15 -05:00
3654a6a5d3
- queueForOffline now saves url, method, headers, body (replay-safe for localStorage); legacy items with options only still replayed via fallback - processOfflineQueue builds fetch options from stored method/body so replayed requests send the same payload when back online - Make queueForOffline async and await it in handleFetchResponse/handleFetchException - Add tests asserting queue stores method/body and replay uses them
953 lines
32 KiB
JavaScript
953 lines
32 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;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.error-retry-btn {
|
|
padding: 8px 16px;
|
|
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;
|
|
font-weight: 500;
|
|
transition: all 0.2s;
|
|
align-self: flex-start;
|
|
}
|
|
|
|
.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 {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.error-recovery-btn {
|
|
padding: 6px 12px;
|
|
background: rgba(255, 255, 255, 0.15);
|
|
color: white;
|
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.error-recovery-btn:hover {
|
|
background: rgba(255, 255, 255, 0.25);
|
|
}
|
|
`;
|
|
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) {
|
|
await 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) {
|
|
await 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.type = 'button';
|
|
retryBtn.setAttribute('aria-label', 'Retry');
|
|
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') {
|
|
if (this.isDuplicateError(message)) {
|
|
console.warn('Duplicate error suppressed:', message);
|
|
return;
|
|
}
|
|
try {
|
|
if (window.toastManager && typeof window.toastManager.error === 'function') {
|
|
window.toastManager.error(message, title);
|
|
} else {
|
|
console.error(title + ':', message);
|
|
}
|
|
} catch (e) {
|
|
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 0:
|
|
// Connection errors - offer refresh option
|
|
options.push({
|
|
label: 'Refresh',
|
|
action: () => {
|
|
window.location.reload();
|
|
}
|
|
});
|
|
break;
|
|
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',
|
|
action: () => {
|
|
window.location.reload();
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
/**
|
|
* Offline Queue Management
|
|
* Stores method, headers, and body in a replay-safe form so POST/PUT replay correctly after JSON round-trip.
|
|
*/
|
|
async queueForOffline(url, options, errorId = null) {
|
|
const opts = options || {};
|
|
let method = (opts.method || 'GET').toUpperCase();
|
|
let headers = {};
|
|
let body = null;
|
|
|
|
if (opts.headers) {
|
|
if (opts.headers instanceof Headers) {
|
|
opts.headers.forEach((v, k) => { headers[k] = v; });
|
|
} else if (typeof opts.headers === 'object') {
|
|
headers = { ...opts.headers };
|
|
}
|
|
}
|
|
if (opts.body !== undefined && opts.body !== null) {
|
|
if (typeof opts.body === 'string') {
|
|
body = opts.body;
|
|
} else if (opts.body instanceof Blob) {
|
|
try {
|
|
body = await opts.body.text();
|
|
} catch (e) {
|
|
console.warn('Offline queue: could not read body as text, skipping queue', e);
|
|
return;
|
|
}
|
|
} else if (opts.body instanceof ArrayBuffer) {
|
|
body = new TextDecoder().decode(opts.body);
|
|
} else if (typeof opts.body.toString === 'function') {
|
|
body = opts.body.toString();
|
|
} else {
|
|
try {
|
|
body = JSON.stringify(opts.body);
|
|
} catch (e) {
|
|
console.warn('Offline queue: could not serialize body, skipping queue', e);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const queueItem = {
|
|
url,
|
|
method,
|
|
headers,
|
|
body,
|
|
errorId,
|
|
timestamp: Date.now(),
|
|
retries: 0
|
|
};
|
|
|
|
this.offlineQueue.push(queueItem);
|
|
this.updateOfflineQueueIndicator();
|
|
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 {
|
|
let fetchOptions;
|
|
if (item.method !== undefined || item.body !== undefined) {
|
|
fetchOptions = {
|
|
method: item.method || 'GET',
|
|
headers: item.headers || {}
|
|
};
|
|
if (item.body != null) {
|
|
fetchOptions.body = item.body;
|
|
}
|
|
} else {
|
|
fetchOptions = item.options || { method: 'GET' };
|
|
}
|
|
const response = await fetch(item.url, fetchOptions);
|
|
if (response.ok && item.errorId) {
|
|
window.toastManager?.dismiss(item.errorId);
|
|
}
|
|
} catch (error) {
|
|
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();
|
|
}
|
|
}
|
|
});
|
|
|