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 |
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.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.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.
Step 5 — Deep Link Navigation Uses Router.navigateByUrl
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() |