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' }} } + + + + + +
+ Benachrichtigungen + +
+ @if (notifications().length === 0) { +
Keine Benachrichtigungen
+ } @else { + @for (item of notifications(); track item.id) { + + } + @if (currentTeamId(); as teamId) { + Alle anzeigen + } + } +
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); + } + } }