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'; const unreadClass = notification.read ? '' : 'unread';
return ` 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"> <div class="notification-icon">
<i class="fas ${icon}"></i> <i class="fas ${icon}"></i>
</div> </div>
@@ -8451,11 +8451,14 @@ function initNotificationTray() {
return div.innerHTML; return div.innerHTML;
} }
// Update badge // Update badge — only badge-worthy unread (danger/warning / explicit badge:true)
function updateBadge() { function updateBadge() {
const unreadCount = notificationManager.getUnreadCount(); const badgeCount =
if (unreadCount > 0) { typeof notificationManager.getBadgeCount === 'function'
notificationBadge.textContent = unreadCount > 99 ? '99+' : unreadCount; ? notificationManager.getBadgeCount()
: notificationManager.getUnreadCount();
if (badgeCount > 0) {
notificationBadge.textContent = badgeCount > 99 ? '99+' : badgeCount;
notificationBadge.style.display = 'flex'; notificationBadge.style.display = 'flex';
} else { } else {
notificationBadge.textContent = ''; notificationBadge.textContent = '';
+150 -100
View File
@@ -1,22 +1,39 @@
/** /**
* Notification Manager * 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 STORAGE_KEY = 'peardock_notifications'
const MAX_NOTIFICATIONS = 100; // Limit stored 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 { class NotificationManager {
constructor() { constructor() {
this.notifications = []; this.notifications = []
this.listeners = []; this.listeners = []
this._storageLoaded = false; this._storageLoaded = false
// Defer loading from storage to not block initialization // Defer loading from storage to not block initialization
if (typeof requestIdleCallback !== 'undefined') { if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(() => this.loadFromStorage(), { timeout: 1000 }); requestIdleCallback(() => this.loadFromStorage(), { timeout: 1000 })
} else { } else {
// Fallback for browsers without requestIdleCallback setTimeout(() => this.loadFromStorage(), 0)
setTimeout(() => this.loadFromStorage(), 0);
} }
} }
@@ -24,26 +41,31 @@ class NotificationManager {
* Load notifications from localStorage * Load notifications from localStorage
*/ */
loadFromStorage() { loadFromStorage() {
if (this._storageLoaded) return; if (this._storageLoaded) return
try { try {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY)
if (stored) { if (stored) {
const parsed = JSON.parse(stored); const parsed = JSON.parse(stored)
// Convert date strings back to Date objects this.notifications = parsed.map((n) => {
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, ...n,
timestamp: new Date(n.timestamp) type,
})); badge,
// Limit to most recent notifications timestamp: new Date(n.timestamp),
this.notifications = this.notifications.slice(-MAX_NOTIFICATIONS);
} }
this._storageLoaded = true; })
// Notify listeners after loading this.notifications = this.notifications.slice(-MAX_NOTIFICATIONS)
this.notify(); }
this._storageLoaded = true
this.notify()
} catch (err) { } catch (err) {
console.error('[ERROR] Failed to load notifications from storage:', err); console.error('[ERROR] Failed to load notifications from storage:', err)
this.notifications = []; this.notifications = []
this._storageLoaded = true; this._storageLoaded = true
} }
} }
@@ -52,114 +74,124 @@ class NotificationManager {
*/ */
saveToStorage() { saveToStorage() {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.notifications)); localStorage.setItem(STORAGE_KEY, JSON.stringify(this.notifications))
} catch (err) { } 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 * Subscribe to notification changes
* @param {Function} callback - Callback function * @param {Function} callback - (notifications, badgeCount) => void
*/ */
subscribe(callback) { subscribe(callback) {
this.listeners.push(callback); this.listeners.push(callback)
// Immediately notify with current state callback(this.notifications, this.getBadgeCount())
callback(this.notifications, this.getUnreadCount());
} }
/** /**
* Unsubscribe from notification changes * Unsubscribe from notification changes
* @param {Function} callback - Callback function to remove * @param {Function} callback
*/ */
unsubscribe(callback) { unsubscribe(callback) {
this.listeners = this.listeners.filter(listener => listener !== callback); this.listeners = this.listeners.filter((listener) => listener !== callback)
} }
/** /**
* Notify all subscribers * Notify all subscribers
*/ */
notify() { notify() {
this.listeners.forEach(callback => { const badgeCount = this.getBadgeCount()
this.listeners.forEach((callback) => {
try { try {
callback(this.notifications, this.getUnreadCount()); callback(this.notifications, badgeCount)
} catch (err) { } catch (err) {
console.error('[ERROR] Notification listener error:', err); console.error('[ERROR] Notification listener error:', err)
} }
}); })
} }
/** /**
* Add a new notification * Add a new notification (always stored in the center).
* @param {string} type - Notification type (success, danger, warning, info) * @param {string} type - success | danger | warning | info
* @param {string} message - Notification message * @param {string} message
* @param {Object} options - Additional options (autoDismiss, duration, etc.) * @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 * @returns {string} Notification ID
*/ */
add(type, message, options = {}) { add(type, message, options = {}) {
const id = this.generateId(); const id = this.generateId()
const tone = String(type || 'info')
const badge = deservesBadge(tone, options)
const notification = { const notification = {
id, id,
type, type: tone,
message, message,
timestamp: new Date(), timestamp: new Date(),
read: false, // History-only items are treated as already "seen" for badge purposes
autoDismiss: options.autoDismiss !== false, // Default to true read: !badge,
duration: options.duration || 5000 badge,
}; autoDismiss: options.autoDismiss !== false,
duration: options.duration || 5000,
this.notifications.unshift(notification); // Add to beginning key: options.key || null,
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);
} }
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 * Remove a notification
* @param {string} id - Notification ID * @param {string} id
*/ */
remove(id) { remove(id) {
this.notifications = this.notifications.filter(n => n.id !== id); this.notifications = this.notifications.filter((n) => n.id !== id)
this.saveToStorage(); this.saveToStorage()
this.notify(); this.notify()
} }
/** /**
* Mark notification as read * Mark notification as read (clears badge contribution if badge-worthy)
* @param {string} id - Notification ID * @param {string} id
*/ */
markAsRead(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) { if (notification && !notification.read) {
notification.read = true; notification.read = true
this.saveToStorage(); this.saveToStorage()
this.notify(); this.notify()
} }
} }
/** /**
* Mark all notifications as read * Mark all notifications as read (clears bell badge)
*/ */
markAllAsRead() { markAllAsRead() {
let changed = false; let changed = false
this.notifications.forEach(n => { this.notifications.forEach((n) => {
if (!n.read) { if (!n.read) {
n.read = true; n.read = true
changed = true; changed = true
} }
}); })
if (changed) { if (changed) {
this.saveToStorage(); this.saveToStorage()
this.notify(); this.notify()
} }
} }
@@ -167,58 +199,76 @@ class NotificationManager {
* Clear all notifications * Clear all notifications
*/ */
clearAll() { clearAll() {
this.notifications = []; this.notifications = []
this.saveToStorage(); this.saveToStorage()
this.notify(); this.notify()
} }
/** /**
* Get unread count * Count that drives the bell badge: unread + badge-worthy only.
* @returns {number} Number of unread notifications * @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() { getUnreadCount() {
// Ensure storage is loaded return this.getBadgeCount()
if (!this._storageLoaded) {
this.loadFromStorage();
} }
return this.notifications.filter(n => !n.read).length;
/**
* Unread records in the center (includes history-only if ever marked unread).
* @returns {number}
*/
getCenterUnreadCount() {
if (!this._storageLoaded) {
this.loadFromStorage()
}
return this.notifications.filter((n) => !n.read).length
} }
/** /**
* Get notifications with optional filtering * Get notifications with optional filtering
* @param {Object} filters - Filter options (type, read) * @param {{ type?: string, read?: boolean, badge?: boolean }} [filters]
* @returns {Array} Filtered notifications * @returns {Array}
*/ */
getNotifications(filters = {}) { getNotifications(filters = {}) {
// Ensure storage is loaded
if (!this._storageLoaded) { if (!this._storageLoaded) {
this.loadFromStorage(); this.loadFromStorage()
} }
let filtered = [...this.notifications]; let filtered = [...this.notifications]
if (filters.type && filters.type !== 'all') { 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) { 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}
* @returns {string} Unique ID
*/ */
generateId() { 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()
const notificationManager = new NotificationManager();
export default 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) * toast / forceToast: show top-center toast (default false — avoid double UI)
* tray: write notification history bell (default true when not handled by job) * tray: write notification history bell (default true when not handled by job)
* job: allow routing into active job tray (default true) * 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 = {}) { export function showAlert(type, message, options = {}) {
const text = String(message ?? ''); const text = String(message ?? '');