feat: add NotificationsStore
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user