Only badge the bell for high-signal notifications
CI / test (push) Successful in 10m3s

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:
2026-07-10 23:42:35 -04:00
parent 74cd2f4a2e
commit 182fe1e675
3 changed files with 160 additions and 106 deletions
+8 -5
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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 ?? '');