diff --git a/myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.spec.ts b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.spec.ts new file mode 100644 index 0000000..172e0bf --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.spec.ts @@ -0,0 +1,102 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { NotificationsApi } from './notifications-api'; +import { NotificationsStore } from './notifications-store'; + +describe('NotificationsStore', () => { + let api: { + loadUnreadCount: ReturnType; + loadNotifications: ReturnType; + markRead: ReturnType; + markAllRead: ReturnType; + }; + let store: NotificationsStore; + + beforeEach(() => { + api = { + loadUnreadCount: vi.fn().mockReturnValue(of({ count: 0 })), + loadNotifications: vi.fn().mockReturnValue(of({ data: [], page: 1, limit: 20, total: 0, hasNextPage: false })), + markRead: vi.fn().mockReturnValue(of(undefined)), + markAllRead: vi.fn().mockReturnValue(of(undefined)), + }; + TestBed.configureTestingModule({ providers: [{ provide: NotificationsApi, useValue: api }] }); + store = TestBed.inject(NotificationsStore); + }); + + it('polls the unread count immediately when polling starts for a team', () => { + api.loadUnreadCount.mockReturnValue(of({ count: 4 })); + + store.startPolling(10); + + expect(api.loadUnreadCount).toHaveBeenCalledWith(10); + expect(store.unreadCount()).toBe(4); + }); + + it('does not start a second poll loop for the same team id', () => { + store.startPolling(10); + store.startPolling(10); + + expect(api.loadUnreadCount).toHaveBeenCalledTimes(1); + }); + + it('switches polling to a newly routed team', () => { + store.startPolling(10); + api.loadUnreadCount.mockReturnValue(of({ count: 7 })); + + store.startPolling(11); + + expect(api.loadUnreadCount).toHaveBeenCalledWith(11); + expect(store.unreadCount()).toBe(7); + }); + + it('loads the recent notification list', () => { + const data = [ + { + id: 1, + event: 'player_creation' as const, + actorUserId: 9, + payload: { playerId: 21, playerName: 'Ada Lovelace' }, + read: false, + createdAt: '2026-08-04T10:00:00.000Z', + }, + ]; + api.loadNotifications.mockReturnValue(of({ data, page: 1, limit: 20, total: 1, hasNextPage: false })); + + store.loadRecent(10); + + expect(api.loadNotifications).toHaveBeenCalledWith(10, { page: 1, limit: 20 }); + expect(store.notifications()).toEqual(data); + }); + + it('marks a notification as read locally and decrements the unread count', () => { + api.loadUnreadCount.mockReturnValue(of({ count: 3 })); + store.startPolling(10); + const data = [ + { id: 1, event: 'player_creation' as const, actorUserId: 9, payload: {}, read: false, createdAt: 'x' }, + ]; + api.loadNotifications.mockReturnValue(of({ data, page: 1, limit: 20, total: 1, hasNextPage: false })); + store.loadRecent(10); + + store.markRead(10, 1); + + expect(api.markRead).toHaveBeenCalledWith(10, 1); + expect(store.notifications()[0].read).toBe(true); + expect(store.unreadCount()).toBe(2); + }); + + it('marks all notifications as read locally and zeroes the unread count', () => { + api.loadUnreadCount.mockReturnValue(of({ count: 5 })); + store.startPolling(10); + const data = [ + { id: 1, event: 'player_creation' as const, actorUserId: 9, payload: {}, read: false, createdAt: 'x' }, + ]; + api.loadNotifications.mockReturnValue(of({ data, page: 1, limit: 20, total: 1, hasNextPage: false })); + store.loadRecent(10); + + store.markAllRead(10); + + expect(api.markAllRead).toHaveBeenCalledWith(10); + expect(store.notifications()[0].read).toBe(true); + expect(store.unreadCount()).toBe(0); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.ts b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.ts new file mode 100644 index 0000000..413a64d --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/notifications/notifications-store.ts @@ -0,0 +1,71 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Subject, interval } from 'rxjs'; +import { startWith, switchMap } from 'rxjs/operators'; +import { NotificationItem } from '../../models/notification.model'; +import { NotificationsApi } from './notifications-api'; + +const POLL_INTERVAL_MS = 30000; +const DROPDOWN_PAGE_SIZE = 20; + +@Injectable({ providedIn: 'root' }) +export class NotificationsStore { + private readonly api = inject(NotificationsApi); + + private readonly unreadCountSignal = signal(0); + private readonly notificationsSignal = signal([]); + private readonly loadingSignal = signal(false); + private readonly pollingTeamId = signal(null); + private readonly pollRequests = new Subject(); + + readonly unreadCount = this.unreadCountSignal.asReadonly(); + readonly notifications = this.notificationsSignal.asReadonly(); + readonly loading = this.loadingSignal.asReadonly(); + + constructor() { + this.pollRequests + .pipe( + switchMap((teamId) => + interval(POLL_INTERVAL_MS).pipe( + startWith(-1), + switchMap(() => this.api.loadUnreadCount(teamId)), + ), + ), + takeUntilDestroyed(), + ) + .subscribe((result) => this.unreadCountSignal.set(result.count)); + } + + startPolling(teamId: number): void { + if (this.pollingTeamId() === teamId) return; + this.pollingTeamId.set(teamId); + this.pollRequests.next(teamId); + } + + loadRecent(teamId: number): void { + this.loadingSignal.set(true); + this.api.loadNotifications(teamId, { page: 1, limit: DROPDOWN_PAGE_SIZE }).subscribe({ + next: (page) => { + this.notificationsSignal.set(page.data); + this.loadingSignal.set(false); + }, + error: () => this.loadingSignal.set(false), + }); + } + + markRead(teamId: number, id: number): void { + this.api.markRead(teamId, id).subscribe(() => { + this.notificationsSignal.update((items) => + items.map((item) => (item.id === id ? { ...item, read: true } : item)), + ); + this.unreadCountSignal.update((count) => Math.max(0, count - 1)); + }); + } + + markAllRead(teamId: number): void { + this.api.markAllRead(teamId).subscribe(() => { + this.notificationsSignal.update((items) => items.map((item) => ({ ...item, read: true }))); + this.unreadCountSignal.set(0); + }); + } +}