MorphGuard / static /js /notification_client.js
juanquy's picture
Initial clean commit of modular MorphGuard
2978bba
Raw
History Blame Contribute Delete
48.4 kB
/**
* MorphGuard Notification Client
*
* Provides client-side integration with the server's notification system,
* including intelligent notification handling, priority management,
* context-aware display, and cognitive load management.
*/
class NotificationClient {
/**
* Initialize the notification client
* @param {Object} options - Configuration options
*/
constructor(options = {}) {
// Default options
this.options = {
pollInterval: 10000, // 10 seconds
maxNotifications: 5, // Maximum number of notifications to show at once
autoHideDelay: 5000, // Auto-hide delay for toast notifications (ms)
notificationContainer: '#notification-container',
toastContainer: '#toast-container',
modalContainer: '#modal-container',
enablePolling: true,
enableWebSocket: true,
debug: false,
onNotificationReceived: null, // Callback for new notifications
onNotificationRead: null, // Callback for read notifications
serverUrl: '/api/notifications',
userPreferences: null, // User notification preferences
...options
};
// Notification state
this.notifications = [];
this.unreadCount = 0;
this.activeNotifications = new Set();
this.lastPollTime = 0;
this.isPolling = false;
this.webSocket = null;
this.isWebSocketConnected = false;
this.interactionHistory = [];
this.cognitiveLoadEstimate = 0.0;
this.attentionScore = 1.0;
this.throttleTimers = {};
// Session context
this.sessionContext = {
startTime: Date.now(),
lastActivity: Date.now(),
pageTransitions: 0,
formInteractions: 0,
scrollEvents: 0,
clickEvents: 0,
keyboardEvents: 0,
errors: 0,
activeModals: 0,
isActive: true,
timeOnPage: 0
};
// Initialize
this.init();
}
/**
* Initialize the notification client
*/
init() {
// Create notification containers if they don't exist
this.createContainers();
// Set up event listeners
this.setupEventListeners();
// Initialize WebSocket if enabled
if (this.options.enableWebSocket) {
this.connectWebSocket();
}
// Start polling for notifications
if (this.options.enablePolling) {
this.startPolling();
}
// Log initialization
this.log('NotificationClient initialized');
}
/**
* Create notification containers if they don't exist
*/
createContainers() {
const createContainer = (selector, className) => {
if (!document.querySelector(selector)) {
const container = document.createElement('div');
container.id = selector.substring(1); // Remove the # from the selector
container.className = className;
document.body.appendChild(container);
}
};
createContainer(this.options.notificationContainer, 'mg-notification-container');
createContainer(this.options.toastContainer, 'mg-toast-container');
createContainer(this.options.modalContainer, 'mg-modal-container');
}
/**
* Set up event listeners
*/
setupEventListeners() {
// Track user activity
document.addEventListener('click', e => this.trackActivity('click', e));
document.addEventListener('keydown', e => this.trackActivity('keydown', e));
document.addEventListener('scroll', e => this.trackActivity('scroll', e));
document.addEventListener('mousemove', e => this.throttle('mousemove', () => this.trackActivity('mousemove'), 1000));
// Track form interactions
document.addEventListener('submit', e => this.trackActivity('form_submit', e));
document.addEventListener('change', e => this.trackActivity('form_change', e));
// Track page visibility
document.addEventListener('visibilitychange', () => this.handleVisibilityChange());
// Track page navigation
window.addEventListener('popstate', () => this.trackActivity('navigation'));
// Track errors
window.addEventListener('error', e => this.trackActivity('error', e));
// Set up periodic context updates
setInterval(() => this.updateContext(), 5000);
}
/**
* Throttle an event callback
* @param {string} key - Throttle key
* @param {Function} callback - Function to throttle
* @param {number} limit - Throttle time in ms
*/
throttle(key, callback, limit) {
if (!this.throttleTimers[key]) {
callback();
this.throttleTimers[key] = setTimeout(() => {
delete this.throttleTimers[key];
}, limit);
}
}
/**
* Track user activity
* @param {string} type - Activity type
* @param {Event} event - Browser event
*/
trackActivity(type, event) {
const now = Date.now();
this.sessionContext.lastActivity = now;
this.sessionContext.isActive = true;
// Update specific activity counters
switch (type) {
case 'click':
this.sessionContext.clickEvents++;
break;
case 'keydown':
this.sessionContext.keyboardEvents++;
break;
case 'scroll':
this.sessionContext.scrollEvents++;
break;
case 'navigation':
this.sessionContext.pageTransitions++;
break;
case 'form_submit':
case 'form_change':
this.sessionContext.formInteractions++;
break;
case 'error':
this.sessionContext.errors++;
break;
}
// Record in interaction history (limit to last 50 events)
this.interactionHistory.push({
type,
timestamp: now,
data: event ? this.sanitizeEventData(event) : null
});
if (this.interactionHistory.length > 50) {
this.interactionHistory.shift();
}
// Update cognitive load estimate
this.updateCognitiveLoadEstimate();
}
/**
* Sanitize event data for storage
* @param {Event} event - Browser event
* @returns {Object} Sanitized event data
*/
sanitizeEventData(event) {
// Extract only needed data to avoid circular references
if (!event) return null;
const data = {};
if (event.target) {
data.target = {
tagName: event.target.tagName,
id: event.target.id,
className: event.target.className,
type: event.target.type
};
}
if (event.key) {
data.key = event.key;
}
return data;
}
/**
* Handle visibility change events
*/
handleVisibilityChange() {
if (document.visibilityState === 'visible') {
this.sessionContext.isActive = true;
this.sessionContext.lastActivity = Date.now();
// Refresh notifications when tab becomes visible
this.fetchNotifications();
} else {
this.sessionContext.isActive = false;
}
}
/**
* Update the session context
*/
updateContext() {
const now = Date.now();
// Update time on page
this.sessionContext.timeOnPage = Math.floor((now - this.sessionContext.startTime) / 1000);
// Check if user is still active (inactive after 60 seconds)
if (now - this.sessionContext.lastActivity > 60000) {
this.sessionContext.isActive = false;
}
// Calculate attention score based on recent activity
this.updateAttentionScore();
}
/**
* Update cognitive load estimate based on recent activity
*/
updateCognitiveLoadEstimate() {
// Simple cognitive load model based on:
// 1. Number of active notifications
// 2. Form interaction complexity
// 3. Error rate
// 4. Page transition frequency
// 5. Input frequency
const activeNotificationLoad = Math.min(this.activeNotifications.size / 5, 1) * 0.3;
// Calculate input frequency (events in the last 30 seconds)
const recentEvents = this.interactionHistory.filter(e =>
Date.now() - e.timestamp < 30000
).length;
const inputLoad = Math.min(recentEvents / 20, 1) * 0.2;
// Calculate error impact
const errorLoad = Math.min(this.sessionContext.errors / 5, 1) * 0.3;
// Calculate form complexity
const formLoad = Math.min(this.sessionContext.formInteractions / 10, 1) * 0.1;
// Calculate page transition frequency
const pageTransitionRate = this.sessionContext.pageTransitions /
(Math.max(this.sessionContext.timeOnPage, 1) / 60); // transitions per minute
const navigationLoad = Math.min(pageTransitionRate / 3, 1) * 0.1;
// Calculate combined load (0-1 scale)
this.cognitiveLoadEstimate = Math.min(
activeNotificationLoad + inputLoad + errorLoad + formLoad + navigationLoad,
1.0
);
this.log(`Cognitive load estimate updated: ${this.cognitiveLoadEstimate.toFixed(2)}`);
}
/**
* Update attention score based on recent activity
*/
updateAttentionScore() {
// Simple attention model based on:
// 1. Time since last activity
// 2. Recent input frequency
// 3. Page visibility
// Calculate time factor (attention decreases with inactivity)
const timeSinceActivity = (Date.now() - this.sessionContext.lastActivity) / 1000; // seconds
const timeFactor = Math.max(1 - (timeSinceActivity / 60), 0); // 0 after 60 seconds of inactivity
// Calculate activity factor (recent events indicate attention)
const recentEvents = this.interactionHistory.filter(e =>
Date.now() - e.timestamp < 10000 // events in last 10 seconds
).length;
const activityFactor = Math.min(recentEvents / 5, 1);
// Page visibility factor
const visibilityFactor = document.visibilityState === 'visible' ? 1 : 0;
// Combined attention score (0-1 scale)
this.attentionScore = Math.min(
(timeFactor * 0.4) + (activityFactor * 0.3) + (visibilityFactor * 0.3),
1.0
);
this.log(`Attention score updated: ${this.attentionScore.toFixed(2)}`);
}
/**
* Connect to WebSocket for real-time notifications
*/
connectWebSocket() {
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/notifications`;
this.webSocket = new WebSocket(wsUrl);
this.webSocket.onopen = () => {
this.isWebSocketConnected = true;
this.log('WebSocket connected');
};
this.webSocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'notification') {
this.handleNewNotification(data.notification);
} else if (data.type === 'read') {
this.handleNotificationRead(data.notificationId);
}
} catch (err) {
this.log('Error processing WebSocket message:', err);
}
};
this.webSocket.onclose = () => {
this.isWebSocketConnected = false;
this.log('WebSocket disconnected, reconnecting in 5 seconds...');
// Reconnect after 5 seconds
setTimeout(() => this.connectWebSocket(), 5000);
};
this.webSocket.onerror = (error) => {
this.log('WebSocket error:', error);
this.isWebSocketConnected = false;
};
} catch (err) {
this.log('Error connecting to WebSocket:', err);
}
}
/**
* Start polling for notifications
*/
startPolling() {
if (this.isPolling) return;
this.isPolling = true;
this.pollForNotifications();
}
/**
* Poll for notifications
*/
pollForNotifications() {
if (!this.isPolling) return;
// Only poll if the page is visible and we're not using WebSockets
if (document.visibilityState === 'visible' && !this.isWebSocketConnected) {
this.fetchNotifications();
}
// Schedule next poll
setTimeout(() => this.pollForNotifications(), this.options.pollInterval);
}
/**
* Fetch notifications from the server
*/
async fetchNotifications() {
try {
this.lastPollTime = Date.now();
const response = await fetch(`${this.options.serverUrl}?since=${this.lastPollTime - this.options.pollInterval}`);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
// Process new notifications
if (data.notifications && Array.isArray(data.notifications)) {
data.notifications.forEach(notification => {
this.handleNewNotification(notification);
});
}
// Update unread count
if (data.unreadCount !== undefined) {
this.unreadCount = data.unreadCount;
this.updateUnreadBadge();
}
} catch (err) {
this.log('Error fetching notifications:', err);
}
}
/**
* Handle a new notification
* @param {Object} notification - Notification data
*/
handleNewNotification(notification) {
// Add to notifications list
this.notifications.unshift(notification);
// Limit the number of notifications we keep in memory
if (this.notifications.length > 100) {
this.notifications = this.notifications.slice(0, 100);
}
// Update unread count
if (notification.state !== 'read') {
this.unreadCount++;
this.updateUnreadBadge();
}
// Determine if we should show the notification based on:
// 1. Priority
// 2. Current cognitive load
// 3. Current attention score
// 4. User preferences
const shouldShow = this.shouldShowNotification(notification);
if (shouldShow) {
// Display the notification based on channel
this.displayNotification(notification);
} else {
// Queue for later if not showing now
this.queueNotificationForLater(notification);
}
// Call notification received callback
if (this.options.onNotificationReceived) {
this.options.onNotificationReceived(notification);
}
}
/**
* Determine if a notification should be shown immediately
* @param {Object} notification - Notification data
* @returns {boolean} Whether to show the notification
*/
shouldShowNotification(notification) {
// Always show critical notifications
if (notification.priority === 'CRITICAL') {
return true;
}
// Check user preferences
const userPrefs = this.options.userPreferences || {};
const categoryPref = userPrefs[notification.category] || 'normal';
if (categoryPref === 'none') {
return false;
}
// Check cognitive load threshold based on priority
const loadThresholds = {
'HIGH': 0.8, // Show high priority unless cognitive load is very high
'MEDIUM': 0.6, // Show medium priority unless cognitive load is high
'LOW': 0.4, // Show low priority only when cognitive load is low
'BACKGROUND': 0.3 // Show background notifications only when cognitive load is very low
};
const loadThreshold = loadThresholds[notification.priority] || 0.6;
if (this.cognitiveLoadEstimate > loadThreshold) {
return false;
}
// Check attention threshold based on priority
const attentionThresholds = {
'HIGH': 0.3, // Show high priority even with low attention
'MEDIUM': 0.5, // Show medium priority with moderate attention
'LOW': 0.7, // Show low priority only with high attention
'BACKGROUND': 0.8 // Show background notifications only with very high attention
};
const attentionThreshold = attentionThresholds[notification.priority] || 0.5;
if (this.attentionScore < attentionThreshold) {
return false;
}
// Check active notification limit
if (this.activeNotifications.size >= this.options.maxNotifications) {
// Only show if higher priority than existing notifications
const lowestPriorityActive = this.getLowestPriorityActiveNotification();
const priorityValues = {
'CRITICAL': 0,
'HIGH': 1,
'MEDIUM': 2,
'LOW': 3,
'BACKGROUND': 4
};
if (priorityValues[notification.priority] >= priorityValues[lowestPriorityActive]) {
return false;
}
}
return true;
}
/**
* Get the lowest priority active notification
* @returns {string} Notification priority
*/
getLowestPriorityActiveNotification() {
let lowestPriority = 'CRITICAL';
const priorityValues = {
'CRITICAL': 0,
'HIGH': 1,
'MEDIUM': 2,
'LOW': 3,
'BACKGROUND': 4
};
for (const id of this.activeNotifications) {
const notification = this.notifications.find(n => n.id === id);
if (notification && priorityValues[notification.priority] > priorityValues[lowestPriority]) {
lowestPriority = notification.priority;
}
}
return lowestPriority;
}
/**
* Queue a notification for later display
* @param {Object} notification - Notification data
*/
queueNotificationForLater(notification) {
// For now, we just log that we're queueing it
this.log(`Queueing notification for later: ${notification.id}`);
// In a full implementation, we would store this and periodically check if conditions are right to show it
}
/**
* Display a notification
* @param {Object} notification - Notification data
*/
displayNotification(notification) {
// Add to active notifications
this.activeNotifications.add(notification.id);
// Display based on channel
if (notification.channels.includes('MODAL')) {
this.showModalNotification(notification);
} else if (notification.channels.includes('TOAST')) {
this.showToastNotification(notification);
} else if (notification.channels.includes('UI')) {
this.showUINotification(notification);
}
// Update cognitive load estimate
this.updateCognitiveLoadEstimate();
}
/**
* Show a modal notification
* @param {Object} notification - Notification data
*/
showModalNotification(notification) {
const modalContainer = document.querySelector(this.options.modalContainer);
if (!modalContainer) return;
// Create modal element
const modal = document.createElement('div');
modal.className = `mg-modal mg-priority-${notification.priority.toLowerCase()}`;
modal.dataset.notificationId = notification.id;
// Add modal content
modal.innerHTML = `
<div class="mg-modal-content">
<div class="mg-modal-header">
<h3>${notification.title}</h3>
<button class="mg-modal-close">&times;</button>
</div>
<div class="mg-modal-body">
<p>${notification.content}</p>
</div>
<div class="mg-modal-footer">
${this.renderNotificationActions(notification)}
</div>
</div>
`;
// Add event listeners
modal.querySelector('.mg-modal-close').addEventListener('click', () => {
this.dismissNotification(notification.id);
modal.remove();
});
// Add action event listeners
const actionButtons = modal.querySelectorAll('.mg-action-button');
actionButtons.forEach(button => {
button.addEventListener('click', (e) => {
const actionId = e.target.dataset.actionId;
this.handleNotificationAction(notification.id, actionId);
// Close modal if the action should dismiss
if (e.target.dataset.dismiss === 'true') {
this.dismissNotification(notification.id);
modal.remove();
}
});
});
// Add to DOM
modalContainer.appendChild(modal);
// Update session context
this.sessionContext.activeModals++;
}
/**
* Show a toast notification
* @param {Object} notification - Notification data
*/
showToastNotification(notification) {
const toastContainer = document.querySelector(this.options.toastContainer);
if (!toastContainer) return;
// Create toast element
const toast = document.createElement('div');
toast.className = `mg-toast mg-priority-${notification.priority.toLowerCase()}`;
toast.dataset.notificationId = notification.id;
// Add icon if available
const iconHtml = notification.icon ?
`<div class="mg-toast-icon"><i class="fa fa-${notification.icon}"></i></div>` : '';
// Add toast content
toast.innerHTML = `
${iconHtml}
<div class="mg-toast-content">
<div class="mg-toast-header">
<h4>${notification.title}</h4>
<button class="mg-toast-close">&times;</button>
</div>
<div class="mg-toast-body">
<p>${notification.content}</p>
</div>
<div class="mg-toast-actions">
${this.renderNotificationActions(notification)}
</div>
</div>
`;
// Add event listeners
toast.querySelector('.mg-toast-close').addEventListener('click', () => {
this.dismissNotification(notification.id);
this.removeToast(toast);
});
// Add action event listeners
const actionButtons = toast.querySelectorAll('.mg-action-button');
actionButtons.forEach(button => {
button.addEventListener('click', (e) => {
const actionId = e.target.dataset.actionId;
this.handleNotificationAction(notification.id, actionId);
// Close toast if the action should dismiss
if (e.target.dataset.dismiss === 'true') {
this.dismissNotification(notification.id);
this.removeToast(toast);
}
});
});
// Add to DOM
toastContainer.appendChild(toast);
// Auto-hide after delay (for non-critical notifications)
if (notification.priority !== 'CRITICAL' && !notification.requires_acknowledgment) {
setTimeout(() => {
this.removeToast(toast);
}, this.options.autoHideDelay);
}
// Animate in
setTimeout(() => {
toast.classList.add('mg-toast-visible');
}, 10);
}
/**
* Remove a toast notification with animation
* @param {HTMLElement} toast - Toast element
*/
removeToast(toast) {
toast.classList.remove('mg-toast-visible');
toast.classList.add('mg-toast-hiding');
// Remove from DOM after animation
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
// Remove from active notifications
const notificationId = toast.dataset.notificationId;
if (notificationId) {
this.activeNotifications.delete(notificationId);
}
// Update cognitive load estimate
this.updateCognitiveLoadEstimate();
}, 300);
}
/**
* Show a UI notification
* @param {Object} notification - Notification data
*/
showUINotification(notification) {
const notificationContainer = document.querySelector(this.options.notificationContainer);
if (!notificationContainer) return;
// Create notification element
const notificationEl = document.createElement('div');
notificationEl.className = `mg-notification mg-priority-${notification.priority.toLowerCase()}`;
notificationEl.dataset.notificationId = notification.id;
// Add icon if available
const iconHtml = notification.icon ?
`<div class="mg-notification-icon"><i class="fa fa-${notification.icon}"></i></div>` : '';
// Add notification content
notificationEl.innerHTML = `
${iconHtml}
<div class="mg-notification-content">
<div class="mg-notification-header">
<h4>${notification.title}</h4>
<span class="mg-notification-time">${this.formatTime(notification.created_at)}</span>
</div>
<div class="mg-notification-body">
<p>${notification.content}</p>
</div>
<div class="mg-notification-actions">
${this.renderNotificationActions(notification)}
</div>
</div>
`;
// Add click event to mark as read
notificationEl.addEventListener('click', (e) => {
// Don't mark as read if clicking an action button
if (e.target.closest('.mg-action-button')) {
return;
}
this.markAsRead(notification.id);
});
// Add action event listeners
const actionButtons = notificationEl.querySelectorAll('.mg-action-button');
actionButtons.forEach(button => {
button.addEventListener('click', (e) => {
const actionId = e.target.dataset.actionId;
this.handleNotificationAction(notification.id, actionId);
});
});
// Add to DOM at the top of the container
if (notificationContainer.firstChild) {
notificationContainer.insertBefore(notificationEl, notificationContainer.firstChild);
} else {
notificationContainer.appendChild(notificationEl);
}
}
/**
* Render notification action buttons
* @param {Object} notification - Notification data
* @returns {string} HTML for action buttons
*/
renderNotificationActions(notification) {
if (!notification.actions || !notification.actions.length) {
return '';
}
return notification.actions.map(action => {
const btnClass = action.style || 'primary';
const dismiss = action.dismiss ? 'true' : 'false';
return `<button class="mg-action-button mg-btn-${btnClass}" data-action-id="${action.id}" data-dismiss="${dismiss}">${action.label}</button>`;
}).join('');
}
/**
* Format a timestamp into a human-readable string
* @param {string} timestamp - ISO timestamp
* @returns {string} Formatted time
*/
formatTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
// Show relative time for recent notifications
if (diff < 60000) {
return 'Just now';
} else if (diff < 3600000) {
const minutes = Math.floor(diff / 60000);
return `${minutes}m ago`;
} else if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
return `${hours}h ago`;
} else {
// For older notifications, show the date
return date.toLocaleDateString();
}
}
/**
* Handle a notification action
* @param {string} notificationId - Notification ID
* @param {string} actionId - Action ID
*/
async handleNotificationAction(notificationId, actionId) {
try {
// Find the notification
const notification = this.notifications.find(n => n.id === notificationId);
if (!notification) return;
// Find the action
const action = notification.actions.find(a => a.id === actionId);
if (!action) return;
// Log the action
this.log(`Notification action ${actionId} for notification ${notificationId}`);
// Send to server
await fetch(`${this.options.serverUrl}/${notificationId}/action`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
actionId,
timestamp: new Date().toISOString()
})
});
// If the action should dismiss the notification
if (action.dismiss) {
this.dismissNotification(notificationId);
}
// Execute client-side action handler if available
if (action.clientHandler && typeof window[action.clientHandler] === 'function') {
window[action.clientHandler](notification, action);
}
} catch (err) {
this.log('Error handling notification action:', err);
}
}
/**
* Mark a notification as read
* @param {string} notificationId - Notification ID
*/
async markAsRead(notificationId) {
try {
// Find the notification
const notification = this.notifications.find(n => n.id === notificationId);
if (!notification) return;
// Skip if already read
if (notification.state === 'read') return;
// Update state locally
notification.state = 'read';
notification.read_at = new Date().toISOString();
// Update unread count
this.unreadCount = Math.max(0, this.unreadCount - 1);
this.updateUnreadBadge();
// Send to server
await fetch(`${this.options.serverUrl}/${notificationId}/read`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
timestamp: notification.read_at
})
});
// Update UI
const notificationEl = document.querySelector(`[data-notification-id="${notificationId}"]`);
if (notificationEl) {
notificationEl.classList.add('mg-notification-read');
}
// Call notification read callback
if (this.options.onNotificationRead) {
this.options.onNotificationRead(notification);
}
} catch (err) {
this.log('Error marking notification as read:', err);
}
}
/**
* Dismiss a notification
* @param {string} notificationId - Notification ID
*/
dismissNotification(notificationId) {
// Remove from active notifications
this.activeNotifications.delete(notificationId);
// Mark as read
this.markAsRead(notificationId);
// Update cognitive load estimate
this.updateCognitiveLoadEstimate();
}
/**
* Acknowledge a notification (for notifications that require acknowledgment)
* @param {string} notificationId - Notification ID
* @param {Object} data - Acknowledgment data
*/
async acknowledgeNotification(notificationId, data = {}) {
try {
// Find the notification
const notification = this.notifications.find(n => n.id === notificationId);
if (!notification) return;
// Skip if already acknowledged or doesn't require acknowledgment
if (notification.state === 'read' || !notification.requires_acknowledgment) return;
// Update state locally
notification.state = 'read';
notification.read_at = new Date().toISOString();
notification.acknowledgment_data = {
...data,
timestamp: notification.read_at
};
// Update unread count
this.unreadCount = Math.max(0, this.unreadCount - 1);
this.updateUnreadBadge();
// Send to server
await fetch(`${this.options.serverUrl}/${notificationId}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(notification.acknowledgment_data)
});
// Update UI
const notificationEl = document.querySelector(`[data-notification-id="${notificationId}"]`);
if (notificationEl) {
notificationEl.classList.add('mg-notification-read');
}
// Call notification read callback
if (this.options.onNotificationRead) {
this.options.onNotificationRead(notification);
}
} catch (err) {
this.log('Error acknowledging notification:', err);
}
}
/**
* Handle notification read event
* @param {string} notificationId - Notification ID
*/
handleNotificationRead(notificationId) {
// Find the notification
const notification = this.notifications.find(n => n.id === notificationId);
if (!notification) return;
// Skip if already read
if (notification.state === 'read') return;
// Update state locally
notification.state = 'read';
notification.read_at = new Date().toISOString();
// Update unread count
this.unreadCount = Math.max(0, this.unreadCount - 1);
this.updateUnreadBadge();
// Update UI
const notificationEl = document.querySelector(`[data-notification-id="${notificationId}"]`);
if (notificationEl) {
notificationEl.classList.add('mg-notification-read');
}
// Call notification read callback
if (this.options.onNotificationRead) {
this.options.onNotificationRead(notification);
}
}
/**
* Update the unread notification badge
*/
updateUnreadBadge() {
// Find all elements with the notification badge class
const badges = document.querySelectorAll('.mg-notification-badge');
badges.forEach(badge => {
if (this.unreadCount > 0) {
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
badge.classList.add('mg-notification-badge-visible');
} else {
badge.classList.remove('mg-notification-badge-visible');
}
});
}
/**
* Get all notifications
* @returns {Array} Notifications
*/
getNotifications() {
return [...this.notifications];
}
/**
* Get unread notifications
* @returns {Array} Unread notifications
*/
getUnreadNotifications() {
return this.notifications.filter(n => n.state !== 'read');
}
/**
* Get notifications by category
* @param {string} category - Notification category
* @returns {Array} Notifications in the category
*/
getNotificationsByCategory(category) {
return this.notifications.filter(n => n.category === category);
}
/**
* Clear all notifications
*/
async clearAllNotifications() {
try {
// Clear on server
await fetch(`${this.options.serverUrl}/clear`, {
method: 'POST'
});
// Clear locally
this.notifications = [];
this.unreadCount = 0;
this.activeNotifications.clear();
// Update UI
this.updateUnreadBadge();
// Clear notification containers
document.querySelector(this.options.notificationContainer).innerHTML = '';
document.querySelector(this.options.toastContainer).innerHTML = '';
// Update cognitive load estimate
this.updateCognitiveLoadEstimate();
} catch (err) {
this.log('Error clearing notifications:', err);
}
}
/**
* Get session context for the server
* @returns {Object} Session context
*/
getContextForServer() {
return {
session_data: {
is_active: this.sessionContext.isActive,
last_activity: this.sessionContext.lastActivity,
time_on_page: this.sessionContext.timeOnPage,
page_transitions: this.sessionContext.pageTransitions,
form_interactions: this.sessionContext.formInteractions
},
cognitive_load: this.cognitiveLoadEstimate,
attention_score: this.attentionScore,
device_info: {
viewport_width: window.innerWidth,
viewport_height: window.innerHeight,
user_agent: navigator.userAgent,
is_mobile: /Mobi|Android/i.test(navigator.userAgent)
}
};
}
/**
* Set user preferences for notifications
* @param {Object} preferences - User preferences
*/
setUserPreferences(preferences) {
this.options.userPreferences = preferences;
}
/**
* Log debug messages
* @param {...any} args - Log arguments
*/
log(...args) {
if (this.options.debug) {
console.log('[NotificationClient]', ...args);
}
}
}
// CSS Styles for notifications
const addNotificationStyles = () => {
const style = document.createElement('style');
style.textContent = `
/* Notification containers */
.mg-notification-container {
max-height: 600px;
overflow-y: auto;
width: 320px;
}
.mg-toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
max-width: 350px;
}
.mg-modal-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
/* Notification badge */
.mg-notification-badge {
display: none;
min-width: 20px;
height: 20px;
border-radius: 10px;
background-color: #e74c3c;
color: white;
font-size: 12px;
text-align: center;
line-height: 20px;
padding: 0 4px;
}
.mg-notification-badge-visible {
display: inline-block;
}
/* UI Notification */
.mg-notification {
display: flex;
padding: 12px;
margin-bottom: 8px;
border-radius: 4px;
background-color: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
border-left: 4px solid #3498db;
transition: all 0.2s ease;
}
.mg-notification:hover {
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16);
}
.mg-notification-read {
opacity: 0.7;
border-left-color: #bdc3c7;
}
.mg-notification-content {
flex: 1;
}
.mg-notification-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.mg-notification-header h4 {
margin: 0;
font-size: 14px;
font-weight: bold;
}
.mg-notification-time {
font-size: 12px;
color: #7f8c8d;
}
.mg-notification-body p {
margin: 0;
font-size: 13px;
color: #2c3e50;
}
.mg-notification-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
/* Toast notification */
.mg-toast {
display: flex;
padding: 12px;
margin-bottom: 10px;
border-radius: 4px;
background-color: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-width: 100%;
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s ease;
}
.mg-toast-visible {
opacity: 1;
transform: translateY(0);
}
.mg-toast-hiding {
opacity: 0;
transform: translateY(-20px);
}
.mg-toast-icon {
margin-right: 12px;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.mg-toast-content {
flex: 1;
}
.mg-toast-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.mg-toast-header h4 {
margin: 0;
font-size: 14px;
font-weight: bold;
}
.mg-toast-close {
background: none;
border: none;
font-size: 16px;
cursor: pointer;
padding: 0;
color: #7f8c8d;
}
.mg-toast-body p {
margin: 0;
font-size: 13px;
color: #2c3e50;
}
.mg-toast-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
/* Modal notification */
.mg-modal {
width: 90%;
max-width: 500px;
background-color: #fff;
border-radius: 4px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.mg-modal-content {
padding: 0;
}
.mg-modal-header {
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.mg-modal-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.mg-modal-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
padding: 0;
color: #7f8c8d;
}
.mg-modal-body {
padding: 16px;
}
.mg-modal-body p {
margin: 0 0 12px 0;
font-size: 14px;
color: #2c3e50;
line-height: 1.5;
}
.mg-modal-footer {
padding: 12px 16px;
border-top: 1px solid #e9ecef;
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Priority colors */
.mg-priority-critical {
border-left-color: #e74c3c;
}
.mg-priority-high {
border-left-color: #e67e22;
}
.mg-priority-medium {
border-left-color: #3498db;
}
.mg-priority-low {
border-left-color: #2ecc71;
}
.mg-priority-background {
border-left-color: #95a5a6;
}
/* Action buttons */
.mg-action-button {
background: none;
border: 1px solid #3498db;
border-radius: 4px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.mg-btn-primary {
background-color: #3498db;
color: white;
}
.mg-btn-primary:hover {
background-color: #2980b9;
}
.mg-btn-secondary {
background-color: transparent;
color: #3498db;
}
.mg-btn-secondary:hover {
background-color: #eef8ff;
}
.mg-btn-danger {
background-color: #e74c3c;
border-color: #e74c3c;
color: white;
}
.mg-btn-danger:hover {
background-color: #c0392b;
}
.mg-btn-success {
background-color: #2ecc71;
border-color: #2ecc71;
color: white;
}
.mg-btn-success:hover {
background-color: #27ae60;
}
`;
document.head.appendChild(style);
};
// Add styles when the DOM is loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', addNotificationStyles);
} else {
addNotificationStyles();
}
// Create a global reference to the notification client
window.notificationClient = null;
// Initialize the notification client
window.initNotificationClient = (options = {}) => {
window.notificationClient = new NotificationClient(options);
return window.notificationClient;
};
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
NotificationClient,
initNotificationClient: (options) => {
window.notificationClient = new NotificationClient(options);
return window.notificationClient;
}
};
}