diff --git a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.html b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.html
index 8d6a51f..6012249 100644
--- a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.html
+++ b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.html
@@ -12,6 +12,46 @@
} @else {
{{ currentTeam()?.name ?? 'TeamWallet' }}
}
+
+
+
+
+
diff --git a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss
index 73dec71..7ac94b5 100644
--- a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss
+++ b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss
@@ -53,3 +53,37 @@ main {
}
}
}
+
+.shell-header-spacer {
+ flex: 1;
+}
+
+.shell-notification-bell {
+ color: var(--mat-sys-on-surface);
+}
+
+.shell-notification-menu {
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 1rem;
+ gap: 0.5rem;
+ }
+
+ &__empty {
+ padding: 1rem;
+ color: var(--mat-sys-on-surface-variant);
+ font-size: 0.875rem;
+ }
+
+ &__item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+
+ &--unread {
+ font-weight: 600;
+ }
+ }
+}
diff --git a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.spec.ts b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.spec.ts
index cdf0d95..4e5d2cd 100644
--- a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.spec.ts
+++ b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.spec.ts
@@ -1,27 +1,55 @@
import { TestBed } from '@angular/core/testing';
+import { signal } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
-import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
+import { ActivatedRoute, ParamMap, Router, convertToParamMap, provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Shell } from './shell';
import { environment } from '../../../../environments/environment';
import { AuthStore } from '../../auth/auth-store';
import { Player } from '../../../models/player.model';
+import { NotificationsStore } from '../../notifications/notifications-store';
describe('Shell', () => {
let httpMock: HttpTestingController;
let authStore: AuthStore;
let routeParams: BehaviorSubject>;
+ let notificationsStore: {
+ unreadCount: ReturnType>;
+ notifications: ReturnType>;
+ startPolling: ReturnType;
+ loadRecent: ReturnType;
+ markRead: ReturnType;
+ markAllRead: ReturnType;
+ };
beforeEach(async () => {
localStorage.clear();
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
+ notificationsStore = {
+ unreadCount: signal(3),
+ notifications: signal([
+ {
+ id: 1,
+ event: 'player_creation',
+ actorUserId: 9,
+ payload: { playerId: 21, playerName: 'Ada Lovelace' },
+ read: false,
+ createdAt: '2026-08-04T10:00:00.000Z',
+ },
+ ]),
+ startPolling: vi.fn(),
+ loadRecent: vi.fn(),
+ markRead: vi.fn(),
+ markAllRead: vi.fn(),
+ };
await TestBed.configureTestingModule({
imports: [Shell],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
+ { provide: NotificationsStore, useValue: notificationsStore },
{
provide: ActivatedRoute,
useValue: { paramMap: routeParams.asObservable() },
@@ -153,4 +181,58 @@ describe('Shell', () => {
expect(httpMock.match((request) => request.url.includes('/teams/')).length).toBe(0);
});
+
+ it('starts polling notifications for the routed team id', () => {
+ const fixture = TestBed.createComponent(Shell);
+ fixture.detectChanges();
+ httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
+ httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
+
+ expect(notificationsStore.startPolling).toHaveBeenCalledWith(5);
+ });
+
+ it('exposes the unread count from the notifications store', () => {
+ const fixture = TestBed.createComponent(Shell);
+ fixture.detectChanges();
+ httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
+ httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
+
+ expect((fixture.componentInstance as any).unreadCount()).toBe(3);
+ });
+
+ it('loads recent notifications when the bell menu is opened', () => {
+ const fixture = TestBed.createComponent(Shell);
+ fixture.detectChanges();
+ httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
+ httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
+
+ (fixture.componentInstance as any).onNotificationsMenuOpened();
+
+ expect(notificationsStore.loadRecent).toHaveBeenCalledWith(5);
+ });
+
+ it('marks a clicked notification as read and navigates to its target', () => {
+ const fixture = TestBed.createComponent(Shell);
+ fixture.detectChanges();
+ httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
+ httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
+ const navigateSpy = vi.spyOn(TestBed.inject(Router), 'navigate');
+
+ const item = notificationsStore.notifications()[0];
+ (fixture.componentInstance as any).onNotificationClick(item);
+
+ expect(notificationsStore.markRead).toHaveBeenCalledWith(5, 1);
+ expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'members', 21]);
+ });
+
+ it('marks all notifications as read', () => {
+ const fixture = TestBed.createComponent(Shell);
+ fixture.detectChanges();
+ httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
+ httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
+
+ (fixture.componentInstance as any).onMarkAllRead();
+
+ expect(notificationsStore.markAllRead).toHaveBeenCalledWith(5);
+ });
});
diff --git a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.ts b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.ts
index ea0a1f7..7246f6a 100644
--- a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.ts
+++ b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.ts
@@ -1,4 +1,4 @@
-import { Component, computed, inject } from '@angular/core';
+import { Component, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
ActivatedRoute,
@@ -7,6 +7,7 @@ import {
RouterLinkActive,
RouterOutlet,
} from '@angular/router';
+import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
@@ -14,7 +15,14 @@ import { MatToolbarModule } from '@angular/material/toolbar';
import { AuthStore } from '../../auth/auth-store';
import { MyTeamsStore } from '../../team/my-teams-store';
import { TeamStore } from '../../team/team-store';
+import { NotificationsStore } from '../../notifications/notifications-store';
+import {
+ notificationIcon,
+ notificationLabel,
+ notificationTarget,
+} from '../../notifications/notification-presentation';
import { UserTeamReference } from '../../../models/user-directory.model';
+import { NotificationItem } from '../../../models/notification.model';
@Component({
selector: 'app-shell',
@@ -26,6 +34,7 @@ import { UserTeamReference } from '../../../models/user-directory.model';
MatIconModule,
MatMenuModule,
MatButtonModule,
+ MatBadgeModule,
],
templateUrl: './shell.html',
styleUrl: './shell.scss',
@@ -36,8 +45,12 @@ export class Shell {
private readonly authStore = inject(AuthStore);
private readonly myTeamsStore = inject(MyTeamsStore);
private readonly teamStore = inject(TeamStore);
+ private readonly notificationsStore = inject(NotificationsStore);
protected readonly currentTeam = this.teamStore.team;
+ protected readonly currentTeamId = signal(null);
+ protected readonly unreadCount = this.notificationsStore.unreadCount;
+ protected readonly notifications = this.notificationsStore.notifications;
protected readonly myTeams = computed(() => {
const seen = new Set();
@@ -57,17 +70,13 @@ export class Shell {
this.myTeamsStore.ensureLoaded(userId);
}
- // A direct subscription (not `effect()` + `toSignal()`) so the initial
- // team load happens synchronously during construction, exactly like
- // `ensureLoaded` above — `ActivatedRoute.paramMap` always replays its
- // current value synchronously to a new subscriber. This keeps the
- // component's behavior deterministic and trivial to test: no signal
- // effect scheduling to wait for.
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
const raw = params.get('id');
const id = raw === null ? Number.NaN : Number(raw);
if (Number.isInteger(id) && id > 0) {
this.teamStore.loadTeam(id);
+ this.currentTeamId.set(id);
+ this.notificationsStore.startPolling(id);
}
});
}
@@ -75,4 +84,33 @@ export class Shell {
protected switchTeam(teamId: number): void {
void this.router.navigate(['/team', teamId, 'overview']);
}
+
+ protected notificationLabel(item: NotificationItem): string {
+ return notificationLabel(item);
+ }
+
+ protected notificationIcon(item: NotificationItem): string {
+ return notificationIcon(item.event);
+ }
+
+ protected onNotificationsMenuOpened(): void {
+ const teamId = this.currentTeamId();
+ if (teamId !== null) {
+ this.notificationsStore.loadRecent(teamId);
+ }
+ }
+
+ protected onNotificationClick(item: NotificationItem): void {
+ const teamId = this.currentTeamId();
+ if (teamId === null) return;
+ this.notificationsStore.markRead(teamId, item.id);
+ void this.router.navigate(notificationTarget(item, teamId));
+ }
+
+ protected onMarkAllRead(): void {
+ const teamId = this.currentTeamId();
+ if (teamId !== null) {
+ this.notificationsStore.markAllRead(teamId);
+ }
+ }
}