Notification Centre — Bell Badge, Panel, Mark-All-Read, and Real-Time Delivery

The notification centre is the in-app inbox — the panel that slides in when the user clicks the bell icon, showing unread notifications with their titles, timestamps, and deep links to the relevant task or workspace. Real-time delivery (Socket.io events updating the unread count badge instantly), read-state management, and the deep-link navigation that takes users directly to the relevant context make this a deceptively complex component despite its simple appearance. This lesson builds the complete notification centre from the bell badge through the panel to the read-tracking.

Notification Centre Component Architecture

Component Responsibility
NotificationBellComponent Bell icon with unread count badge — opens/closes the panel
NotificationPanelComponent Slide-in overlay with notification list
NotificationItemComponent Single notification — icon, title, body, timestamp, read state
NotificationStore Signals for notifications list, unread count, loading state; Socket.io integration
Note: When the notification panel opens, mark all visible notifications as read. Do this with a single POST /api/v1/notifications/mark-all-read call rather than individual read requests per notification. Two things happen simultaneously: the local signal update (all notifications in the store become read: true, unread count resets to 0) and the API call that persists this state. The local update is immediate — the user sees the unread badge clear instantly. The API call is fire-and-forget for UX purposes, though errors should be logged.
Tip: Implement infinite scroll in the notification panel rather than pagination buttons. As the user scrolls toward the bottom of the notification list, load the next page automatically. Angular CDK’s ScrollingModule with cdkVirtualFor handles this efficiently for large notification lists — only rendering the visible items, not all 200 notifications in the DOM simultaneously. For the notification panel (max 400px height), this prevents layout issues when users have many notifications.
Warning: The notification deep-link navigation must handle the case where the target resource no longer exists. A notification for task abc123 might navigate to /w/my-workspace/tasks/abc123 — but the task may have been deleted since the notification was created. The task detail route must handle 404 gracefully, showing an “This task no longer exists” message rather than a broken error state. Always test navigation to deleted resources in your E2E test suite.

Complete Notification Centre

// ── core/stores/notification.store.ts ────────────────────────────────────
import { Injectable, signal, computed, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NotificationService } from '../services/notification.service';
import { SocketService }       from '../services/socket.service';
import { AuthStore }           from './auth.store';
import { SOCKET_EVENTS }       from '@taskmanager/shared';

export interface AppNotification {
    id:        string;
    type:      string;
    title:     string;
    body?:     string;
    link?:     string;
    read:      boolean;
    createdAt: string;
}

@Injectable({ providedIn: 'root' })
export class NotificationStore {
    private service    = inject(NotificationService);
    private socket     = inject(SocketService);
    private authStore  = inject(AuthStore);
    private destroyRef = inject(DestroyRef);

    private _notifications = signal<AppNotification[]>([]);
    private _unreadCount   = signal(0);
    private _loading       = signal(false);
    private _hasMore       = signal(true);
    private _page          = signal(1);

    readonly notifications = this._notifications.asReadonly();
    readonly unreadCount   = this._unreadCount.asReadonly();
    readonly loading       = this._loading.asReadonly();
    readonly hasMore       = this._hasMore.asReadonly();
    readonly hasUnread     = computed(() => this._unreadCount() > 0);
    readonly badgeLabel    = computed(() => {
        const c = this._unreadCount();
        return c > 99 ? '99+' : c > 0 ? String(c) : '';
    });

    // Called on app initialise to get count from user profile
    initUnreadCount(count: number): void {
        this._unreadCount.set(count);
    }

    loadPage(): void {
        if (this._loading() || !this._hasMore()) return;
        this._loading.set(true);

        this.service.getNotifications({ page: this._page(), limit: 20 }).subscribe({
            next: ({ data, meta }) => {
                this._notifications.update(n => [...n, ...data]);
                this._hasMore.set(meta.page < meta.totalPages);
                this._page.update(p => p + 1);
                this._loading.set(false);
            },
            error: () => this._loading.set(false),
        });
    }

    markAllRead(): void {
        // Optimistic update
        this._notifications.update(ns => ns.map(n => ({ ...n, read: true })));
        this._unreadCount.set(0);

        // Persist (fire and forget)
        this.service.markAllRead().subscribe({
            error: () => {
                // On error, reload to restore correct state
                this.reload();
            },
        });
    }

    reload(): void {
        this._notifications.set([]);
        this._page.set(1);
        this._hasMore.set(true);
        this._unreadCount.set(0);
        this.loadPage();
    }

    // Connect to real-time notification events
    connectRealTime(): void {
        this.socket.on<AppNotification & { unreadCount: number }>(SOCKET_EVENTS.NOTIFICATION)
            .pipe(takeUntilDestroyed(this.destroyRef))
            .subscribe(notification => {
                // Prepend new notification to the list
                this._notifications.update(ns => [{
                    id:        notification.id,
                    type:      notification.type,
                    title:     notification.title,
                    body:      notification.body,
                    link:      notification.link,
                    read:      false,
                    createdAt: new Date().toISOString(),
                }, ...ns]);

                // Update unread count from server value
                this._unreadCount.set(notification.unreadCount);
            });
    }
}

// ── core/components/notification-bell/notification-bell.component.ts ──────
@Component({
    selector:   'tm-notification-bell',
    standalone: true,
    changeDetection: ChangeDetectionStrategy.OnPush,
    imports: [CommonModule, NotificationPanelComponent],
    template: `
        <div class="notification-bell" (click)="togglePanel()">
            <!-- Bell icon -->
            <svg class="bell-icon" [class.bell-icon--active]="store.hasUnread()">
                <!-- bell SVG path -->
            </svg>

            <!-- Unread badge -->
            @if (store.badgeLabel()) {
                <span class="unread-badge" data-testid="unread-badge">
                    {{ store.badgeLabel() }}
                </span>
            }
        </div>

        <!-- Notification panel -->
        @if (panelOpen()) {
            <tm-notification-panel
                [notifications]="store.notifications()"
                [loading]="store.loading()"
                [hasMore]="store.hasMore()"
                (close)="closePanel()"
                (loadMore)="store.loadPage()"
                (markAllRead)="store.markAllRead()"
                (notificationClick)="onNotificationClick($event)">
            </tm-notification-panel>
        }
    `,
})
export class NotificationBellComponent implements OnInit {
    store      = inject(NotificationStore);
    private router = inject(Router);

    panelOpen  = signal(false);

    ngOnInit(): void {
        this.store.connectRealTime();
        // Initialise unread count from the loaded user profile
        const user = inject(AuthStore).user();
        if (user) this.store.initUnreadCount((user as any).unreadNotifications ?? 0);
    }

    togglePanel(): void {
        const wasOpen = this.panelOpen();
        this.panelOpen.update(v => !v);

        // Load first page when opening for the first time
        if (!wasOpen && this.store.notifications().length === 0) {
            this.store.loadPage();
        }

        // Mark all read when panel opens
        if (!wasOpen && this.store.hasUnread()) {
            this.store.markAllRead();
        }
    }

    closePanel(): void { this.panelOpen.set(false); }

    onNotificationClick(notification: AppNotification): void {
        this.closePanel();
        if (notification.link) {
            this.router.navigateByUrl(notification.link);
        }
    }
}

How It Works

Step 1 — Unread Count Initialises from the User Profile

The user profile API response includes unreadNotifications: 42 (the denormalised count). When the app initialises (after the silent token refresh), the AuthStore receives the user object and the NotificationStore is initialised with the count. This means the bell badge shows the correct number immediately on page load without a separate API call — the count travels with the user profile that is already being fetched.

Step 2 — Panel Opens with Optimistic Mark-All-Read

When the user clicks the bell, two things happen simultaneously: the panel opens (loading the first page of notifications) and all unread notifications are marked as read. The store updates the local signal immediately (badge clears, all notifications show as read), then the API call persists this. The user never sees a state where the panel is open and the badge still shows a count — it clears at the moment of opening.

Step 3 — Socket.io Delivers Real-Time Notification Updates

When the server emits a notification event to the user’s personal room (user:userId), the Angular store receives it and prepends the new notification to the list (so it appears at the top) and updates the unread count. If the panel is closed, the badge increments. If the panel is open, the new notification appears at the top with an unread indicator. The unreadCount value in the socket event comes from the server (which just incremented the denormalised count) — more reliable than client-side increment which can drift.

Step 4 — badgeLabel Computed Signal Handles the 99+ Case

Displaying “99+” for large unread counts instead of “143” is a small UX detail that prevents badge overflow. The computed signal handles this transformation once: c > 99 ? '99+' : c > 0 ? String(c) : ''. Returning an empty string when count is 0 means the @if (store.badgeLabel()) condition hides the badge entirely — no zero-count badge visible. This computed signal is called on every change detection cycle that reads it, but its value only changes when _unreadCount changes.

Notification deep links are absolute paths like /w/my-workspace/tasks/task-id stored in the notification document. router.navigateByUrl(notification.link) navigates to the full URL, which triggers the workspace and task routes — loading the workspace context, then the task detail. The panel closes before navigation, so the user arrives at the task with a clean UI. If the task has been deleted, the task route handles the 404 gracefully.

Quick Reference

Task Code
Initialise count store.initUnreadCount(user.unreadNotifications)
Load notifications store.loadPage() — appends to list, increments page
Mark all read (optimistic) Update signals immediately → API call fire-and-forget
Real-time notification socket.on(NOTIFICATION).subscribe(n => prepend + update count)
Badge label computed(() => count > 99 ? '99+' : count > 0 ? String(count) : '')
Deep link navigate router.navigateByUrl(notification.link)
Panel toggle + mark read Open panel → if (hasUnread()) markAllRead()

🧠 Test Yourself

A user has 150 unread notifications. They open the notification panel. What does the bell badge show before and after opening, and what API call is made?