feat: add NotificationsStore

This commit is contained in:
Bastian Wagner
2026-08-04 20:01:30 +02:00
parent 6b3a9d69cc
commit 8ace676abf
2 changed files with 173 additions and 0 deletions

View File

@@ -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<typeof vi.fn>;
loadNotifications: ReturnType<typeof vi.fn>;
markRead: ReturnType<typeof vi.fn>;
markAllRead: ReturnType<typeof vi.fn>;
};
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);
});
});

View File

@@ -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<NotificationItem[]>([]);
private readonly loadingSignal = signal(false);
private readonly pollingTeamId = signal<number | null>(null);
private readonly pollRequests = new Subject<number>();
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);
});
}
}