Danger/warning (or explicit badge:true) increment the tray badge; success/info stay in the notification center as history without a badge.
This commit is contained in:
@@ -8421,7 +8421,7 @@ function initNotificationTray() {
|
||||
const unreadClass = notification.read ? '' : 'unread';
|
||||
|
||||
return `
|
||||
<div class="notification-item ${notification.type} ${unreadClass}" data-id="${notification.id}">
|
||||
<div class="notification-item ${notification.type} ${unreadClass}${notification.badge ? ' badge-worthy' : ' history-only'}" data-id="${notification.id}">
|
||||
<div class="notification-icon">
|
||||
<i class="fas ${icon}"></i>
|
||||
</div>
|
||||
@@ -8451,11 +8451,14 @@ function initNotificationTray() {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Update badge
|
||||
// Update badge — only badge-worthy unread (danger/warning / explicit badge:true)
|
||||
function updateBadge() {
|
||||
const unreadCount = notificationManager.getUnreadCount();
|
||||
if (unreadCount > 0) {
|
||||
notificationBadge.textContent = unreadCount > 99 ? '99+' : unreadCount;
|
||||
const badgeCount =
|
||||
typeof notificationManager.getBadgeCount === 'function'
|
||||
? notificationManager.getBadgeCount()
|
||||
: notificationManager.getUnreadCount();
|
||||
if (badgeCount > 0) {
|
||||
notificationBadge.textContent = badgeCount > 99 ? '99+' : badgeCount;
|
||||
notificationBadge.style.display = 'flex';
|
||||
} else {
|
||||
notificationBadge.textContent = '';
|
||||
|
||||
+150
-100
@@ -1,22 +1,39 @@
|
||||
/**
|
||||
* Notification Manager
|
||||
* Centralized notification system with persistence and tray support
|
||||
* Centralized notification system with persistence and tray support.
|
||||
*
|
||||
* Bell badge: only notifications with badge: true count (see deservesBadge).
|
||||
* All notifications still appear in the notification center history.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'peardock_notifications';
|
||||
const MAX_NOTIFICATIONS = 100; // Limit stored notifications
|
||||
const STORAGE_KEY = 'peardock_notifications'
|
||||
const MAX_NOTIFICATIONS = 100 // Limit stored notifications
|
||||
|
||||
/**
|
||||
* Whether a notification should bump the bell badge.
|
||||
* Default: danger + warning only. success/info are history-only unless
|
||||
* options.badge is forced true.
|
||||
* @param {string} type
|
||||
* @param {{ badge?: boolean }} [options]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function deservesBadge(type, options = {}) {
|
||||
if (options.badge === true) return true
|
||||
if (options.badge === false) return false
|
||||
const t = String(type || 'info').toLowerCase()
|
||||
return t === 'danger' || t === 'warning' || t === 'error'
|
||||
}
|
||||
|
||||
class NotificationManager {
|
||||
constructor() {
|
||||
this.notifications = [];
|
||||
this.listeners = [];
|
||||
this._storageLoaded = false;
|
||||
this.notifications = []
|
||||
this.listeners = []
|
||||
this._storageLoaded = false
|
||||
// Defer loading from storage to not block initialization
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(() => this.loadFromStorage(), { timeout: 1000 });
|
||||
requestIdleCallback(() => this.loadFromStorage(), { timeout: 1000 })
|
||||
} else {
|
||||
// Fallback for browsers without requestIdleCallback
|
||||
setTimeout(() => this.loadFromStorage(), 0);
|
||||
setTimeout(() => this.loadFromStorage(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,26 +41,31 @@ class NotificationManager {
|
||||
* Load notifications from localStorage
|
||||
*/
|
||||
loadFromStorage() {
|
||||
if (this._storageLoaded) return;
|
||||
if (this._storageLoaded) return
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Convert date strings back to Date objects
|
||||
this.notifications = parsed.map(n => ({
|
||||
...n,
|
||||
timestamp: new Date(n.timestamp)
|
||||
}));
|
||||
// Limit to most recent notifications
|
||||
this.notifications = this.notifications.slice(-MAX_NOTIFICATIONS);
|
||||
const parsed = JSON.parse(stored)
|
||||
this.notifications = parsed.map((n) => {
|
||||
const type = n.type || 'info'
|
||||
// Backfill badge for older records (infer from type when missing)
|
||||
const badge =
|
||||
typeof n.badge === 'boolean' ? n.badge : deservesBadge(type, {})
|
||||
return {
|
||||
...n,
|
||||
type,
|
||||
badge,
|
||||
timestamp: new Date(n.timestamp),
|
||||
}
|
||||
})
|
||||
this.notifications = this.notifications.slice(-MAX_NOTIFICATIONS)
|
||||
}
|
||||
this._storageLoaded = true;
|
||||
// Notify listeners after loading
|
||||
this.notify();
|
||||
this._storageLoaded = true
|
||||
this.notify()
|
||||
} catch (err) {
|
||||
console.error('[ERROR] Failed to load notifications from storage:', err);
|
||||
this.notifications = [];
|
||||
this._storageLoaded = true;
|
||||
console.error('[ERROR] Failed to load notifications from storage:', err)
|
||||
this.notifications = []
|
||||
this._storageLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,114 +74,124 @@ class NotificationManager {
|
||||
*/
|
||||
saveToStorage() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.notifications));
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.notifications))
|
||||
} catch (err) {
|
||||
console.error('[ERROR] Failed to save notifications to storage:', err);
|
||||
console.error('[ERROR] Failed to save notifications to storage:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to notification changes
|
||||
* @param {Function} callback - Callback function
|
||||
* @param {Function} callback - (notifications, badgeCount) => void
|
||||
*/
|
||||
subscribe(callback) {
|
||||
this.listeners.push(callback);
|
||||
// Immediately notify with current state
|
||||
callback(this.notifications, this.getUnreadCount());
|
||||
this.listeners.push(callback)
|
||||
callback(this.notifications, this.getBadgeCount())
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from notification changes
|
||||
* @param {Function} callback - Callback function to remove
|
||||
* @param {Function} callback
|
||||
*/
|
||||
unsubscribe(callback) {
|
||||
this.listeners = this.listeners.filter(listener => listener !== callback);
|
||||
this.listeners = this.listeners.filter((listener) => listener !== callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all subscribers
|
||||
*/
|
||||
notify() {
|
||||
this.listeners.forEach(callback => {
|
||||
const badgeCount = this.getBadgeCount()
|
||||
this.listeners.forEach((callback) => {
|
||||
try {
|
||||
callback(this.notifications, this.getUnreadCount());
|
||||
callback(this.notifications, badgeCount)
|
||||
} catch (err) {
|
||||
console.error('[ERROR] Notification listener error:', err);
|
||||
console.error('[ERROR] Notification listener error:', err)
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new notification
|
||||
* @param {string} type - Notification type (success, danger, warning, info)
|
||||
* @param {string} message - Notification message
|
||||
* @param {Object} options - Additional options (autoDismiss, duration, etc.)
|
||||
* Add a new notification (always stored in the center).
|
||||
* @param {string} type - success | danger | warning | info
|
||||
* @param {string} message
|
||||
* @param {{
|
||||
* autoDismiss?: boolean,
|
||||
* duration?: number,
|
||||
* badge?: boolean,
|
||||
* key?: string,
|
||||
* }} [options]
|
||||
* badge: force bell badge (true) or suppress (false); default by type
|
||||
* @returns {string} Notification ID
|
||||
*/
|
||||
add(type, message, options = {}) {
|
||||
const id = this.generateId();
|
||||
const id = this.generateId()
|
||||
const tone = String(type || 'info')
|
||||
const badge = deservesBadge(tone, options)
|
||||
const notification = {
|
||||
id,
|
||||
type,
|
||||
type: tone,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
autoDismiss: options.autoDismiss !== false, // Default to true
|
||||
duration: options.duration || 5000
|
||||
};
|
||||
|
||||
this.notifications.unshift(notification); // Add to beginning
|
||||
this.notifications = this.notifications.slice(0, MAX_NOTIFICATIONS); // Limit size
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
|
||||
// Auto-dismiss if enabled
|
||||
if (notification.autoDismiss) {
|
||||
setTimeout(() => {
|
||||
this.remove(id);
|
||||
}, notification.duration);
|
||||
// History-only items are treated as already "seen" for badge purposes
|
||||
read: !badge,
|
||||
badge,
|
||||
autoDismiss: options.autoDismiss !== false,
|
||||
duration: options.duration || 5000,
|
||||
key: options.key || null,
|
||||
}
|
||||
|
||||
return id;
|
||||
this.notifications.unshift(notification)
|
||||
this.notifications = this.notifications.slice(0, MAX_NOTIFICATIONS)
|
||||
this.saveToStorage()
|
||||
this.notify()
|
||||
|
||||
if (notification.autoDismiss) {
|
||||
setTimeout(() => {
|
||||
this.remove(id)
|
||||
}, notification.duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a notification
|
||||
* @param {string} id - Notification ID
|
||||
* @param {string} id
|
||||
*/
|
||||
remove(id) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
this.notifications = this.notifications.filter((n) => n.id !== id)
|
||||
this.saveToStorage()
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
* @param {string} id - Notification ID
|
||||
* Mark notification as read (clears badge contribution if badge-worthy)
|
||||
* @param {string} id
|
||||
*/
|
||||
markAsRead(id) {
|
||||
const notification = this.notifications.find(n => n.id === id);
|
||||
const notification = this.notifications.find((n) => n.id === id)
|
||||
if (notification && !notification.read) {
|
||||
notification.read = true;
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
notification.read = true
|
||||
this.saveToStorage()
|
||||
this.notify()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read
|
||||
* Mark all notifications as read (clears bell badge)
|
||||
*/
|
||||
markAllAsRead() {
|
||||
let changed = false;
|
||||
this.notifications.forEach(n => {
|
||||
let changed = false
|
||||
this.notifications.forEach((n) => {
|
||||
if (!n.read) {
|
||||
n.read = true;
|
||||
changed = true;
|
||||
n.read = true
|
||||
changed = true
|
||||
}
|
||||
});
|
||||
})
|
||||
if (changed) {
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
this.saveToStorage()
|
||||
this.notify()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,58 +199,76 @@ class NotificationManager {
|
||||
* Clear all notifications
|
||||
*/
|
||||
clearAll() {
|
||||
this.notifications = [];
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
this.notifications = []
|
||||
this.saveToStorage()
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count
|
||||
* @returns {number} Number of unread notifications
|
||||
* Count that drives the bell badge: unread + badge-worthy only.
|
||||
* @returns {number}
|
||||
*/
|
||||
getBadgeCount() {
|
||||
if (!this._storageLoaded) {
|
||||
this.loadFromStorage()
|
||||
}
|
||||
return this.notifications.filter((n) => n.badge && !n.read).length
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getBadgeCount — kept for callers; same semantics (badge-only).
|
||||
* @returns {number}
|
||||
*/
|
||||
getUnreadCount() {
|
||||
// Ensure storage is loaded
|
||||
return this.getBadgeCount()
|
||||
}
|
||||
|
||||
/**
|
||||
* Unread records in the center (includes history-only if ever marked unread).
|
||||
* @returns {number}
|
||||
*/
|
||||
getCenterUnreadCount() {
|
||||
if (!this._storageLoaded) {
|
||||
this.loadFromStorage();
|
||||
this.loadFromStorage()
|
||||
}
|
||||
return this.notifications.filter(n => !n.read).length;
|
||||
return this.notifications.filter((n) => !n.read).length
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notifications with optional filtering
|
||||
* @param {Object} filters - Filter options (type, read)
|
||||
* @returns {Array} Filtered notifications
|
||||
* @param {{ type?: string, read?: boolean, badge?: boolean }} [filters]
|
||||
* @returns {Array}
|
||||
*/
|
||||
getNotifications(filters = {}) {
|
||||
// Ensure storage is loaded
|
||||
if (!this._storageLoaded) {
|
||||
this.loadFromStorage();
|
||||
this.loadFromStorage()
|
||||
}
|
||||
|
||||
let filtered = [...this.notifications];
|
||||
let filtered = [...this.notifications]
|
||||
|
||||
if (filters.type && filters.type !== 'all') {
|
||||
filtered = filtered.filter(n => n.type === filters.type);
|
||||
filtered = filtered.filter((n) => n.type === filters.type)
|
||||
}
|
||||
|
||||
if (filters.read !== undefined) {
|
||||
filtered = filtered.filter(n => n.read === filters.read);
|
||||
filtered = filtered.filter((n) => n.read === filters.read)
|
||||
}
|
||||
|
||||
return filtered;
|
||||
if (filters.badge !== undefined) {
|
||||
filtered = filtered.filter((n) => Boolean(n.badge) === Boolean(filters.badge))
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ID for notification
|
||||
* @returns {string} Unique ID
|
||||
* @returns {string}
|
||||
*/
|
||||
generateId() {
|
||||
return `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
return `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const notificationManager = new NotificationManager();
|
||||
|
||||
export default notificationManager;
|
||||
const notificationManager = new NotificationManager()
|
||||
|
||||
export default notificationManager
|
||||
|
||||
@@ -304,6 +304,7 @@ function routeToJobTrayIfActive(type, text) {
|
||||
* toast / forceToast: show top-center toast (default false — avoid double UI)
|
||||
* tray: write notification history bell (default true when not handled by job)
|
||||
* job: allow routing into active job tray (default true)
|
||||
* badge: force (true) or suppress (false) bell badge; default danger/warning only
|
||||
*/
|
||||
export function showAlert(type, message, options = {}) {
|
||||
const text = String(message ?? '');
|
||||
|
||||
Reference in New Issue
Block a user