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