fix: unreadCount badge falls back to timestamp when ack pointer scrolls off page
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-15 04:33:25 +04:00
parent f1ee199d92
commit 1ab689e056
2 changed files with 52 additions and 5 deletions

View File

@@ -54,6 +54,7 @@ describe('AdminOrderWatcherService', () => {
localStorage.removeItem('adminOrderWatcher.lastNotifiedOrderId.v1'); localStorage.removeItem('adminOrderWatcher.lastNotifiedOrderId.v1');
localStorage.removeItem('adminOrderWatcher.lastNotifiedOrderCreatedAt.v1'); localStorage.removeItem('adminOrderWatcher.lastNotifiedOrderCreatedAt.v1');
localStorage.removeItem('adminOrderWatcher.lastAcknowledgedOrderId.v1'); localStorage.removeItem('adminOrderWatcher.lastAcknowledgedOrderId.v1');
localStorage.removeItem('adminOrderWatcher.lastAcknowledgedOrderCreatedAt.v1');
localStorage.removeItem('adminOrderWatcher.pollIntervalMs.v1'); localStorage.removeItem('adminOrderWatcher.pollIntervalMs.v1');
// Defaults to unauthenticated so the reactive effect stays a no-op and the // Defaults to unauthenticated so the reactive effect stays a no-op and the
@@ -157,6 +158,39 @@ describe('AdminOrderWatcherService', () => {
expect(messages.some(m => m.includes('1000'))).toBe(false); expect(messages.some(m => m.includes('1000'))).toBe(false);
})); }));
it('falls back to a timestamp comparison (not the whole page) for unreadCount when the acknowledged order id disappears', fakeAsync(() => {
service.start();
tick(0);
// Second poll introduces 'o3' and pushes 'o2' to index 1.
pollIndex = 1;
ordersByPoll.push([
makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'),
makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'),
makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'),
]);
tick(service.intervalMs());
// Acknowledge everything up to and including 'o3' (createdAt 11:00:00).
service.markAllSeen();
expect(service.unreadCount()).toBe(0);
// Third poll: 'o3' (the acknowledged id) is gone. Page contains orders
// both older and newer than o3's createdAt (2026-08-15T11:00:00.000Z).
pollIndex = 2;
ordersByPoll.push([
makeOrder('o6', '1006', '2026-08-15T13:00:00.000Z'),
makeOrder('o5', '1005', '2026-08-15T12:00:00.000Z'),
makeOrder('o4', '1004', '2026-08-15T10:30:00.000Z'),
makeOrder('o0', '1000', '2026-08-15T08:00:00.000Z'),
]);
tick(service.intervalMs());
// Only o6 and o5 are newer than o3's ack createdAt - the buggy behavior
// would report the full page length (4) instead of the correct count (2).
expect(service.unreadCount()).toBe(2);
}));
it('stop() clears the interval so no further polls occur', fakeAsync(() => { it('stop() clears the interval so no further polls occur', fakeAsync(() => {
const gateway = TestBed.inject(AdminOrdersLocalGateway); const gateway = TestBed.inject(AdminOrdersLocalGateway);
const loadOrdersSpy = spyOn(gateway, 'loadOrders').and.callThrough(); const loadOrdersSpy = spyOn(gateway, 'loadOrders').and.callThrough();

View File

@@ -10,6 +10,7 @@ import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service
const LAST_NOTIFIED_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1'; const LAST_NOTIFIED_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1';
const LAST_NOTIFIED_AT_KEY = 'adminOrderWatcher.lastNotifiedOrderCreatedAt.v1'; const LAST_NOTIFIED_AT_KEY = 'adminOrderWatcher.lastNotifiedOrderCreatedAt.v1';
const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1'; const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1';
const LAST_ACKNOWLEDGED_AT_KEY = 'adminOrderWatcher.lastAcknowledgedOrderCreatedAt.v1';
const POLL_INTERVAL_KEY = 'adminOrderWatcher.pollIntervalMs.v1'; const POLL_INTERVAL_KEY = 'adminOrderWatcher.pollIntervalMs.v1';
export const DEFAULT_POLL_INTERVAL_MS = 15000; export const DEFAULT_POLL_INTERVAL_MS = 15000;
const MIN_POLL_INTERVAL_MS = 1000; const MIN_POLL_INTERVAL_MS = 1000;
@@ -29,6 +30,7 @@ export class AdminOrderWatcherService {
readonly recentOrders: Signal<AdminOrder[]> = this.recentOrdersSignal.asReadonly(); readonly recentOrders: Signal<AdminOrder[]> = this.recentOrdersSignal.asReadonly();
private readonly lastAcknowledgedOrderIdSignal = signal<string | null>(this.storage.getItem(LAST_ACKNOWLEDGED_KEY)); private readonly lastAcknowledgedOrderIdSignal = signal<string | null>(this.storage.getItem(LAST_ACKNOWLEDGED_KEY));
private readonly lastAcknowledgedOrderCreatedAtSignal = signal<string | null>(this.storage.getItem(LAST_ACKNOWLEDGED_AT_KEY));
readonly unreadCount = computed(() => { readonly unreadCount = computed(() => {
const orders = this.recentOrdersSignal(); const orders = this.recentOrdersSignal();
@@ -40,7 +42,14 @@ export class AdminOrderWatcherService {
return orders.length; return orders.length;
} }
const idx = orders.findIndex(order => order.id === ackId); const idx = orders.findIndex(order => order.id === ackId);
return idx === -1 ? orders.length : idx; if (idx !== -1) {
return idx;
}
// The acknowledged order id is no longer present in the current page
// (deleted, or many orders arrived since ack) - fall back to a timestamp
// comparison instead of treating the whole page as unread.
const ackCreatedAt = this.lastAcknowledgedOrderCreatedAtSignal();
return ackCreatedAt === null ? orders.length : orders.filter(order => order.createdAt > ackCreatedAt).length;
}); });
private readonly intervalMsSignal = signal<number>(this.readStoredIntervalMs()); private readonly intervalMsSignal = signal<number>(this.readStoredIntervalMs());
@@ -96,12 +105,14 @@ export class AdminOrderWatcherService {
} }
markAllSeen(): void { markAllSeen(): void {
const newestId = this.recentOrdersSignal()[0]?.id; const newest = this.recentOrdersSignal()[0];
if (!newestId) { if (!newest) {
return; return;
} }
this.lastAcknowledgedOrderIdSignal.set(newestId); this.lastAcknowledgedOrderIdSignal.set(newest.id);
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newestId); this.lastAcknowledgedOrderCreatedAtSignal.set(newest.createdAt);
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newest.id);
this.storage.setItem(LAST_ACKNOWLEDGED_AT_KEY, newest.createdAt);
} }
private scheduleNext(): void { private scheduleNext(): void {
@@ -148,7 +159,9 @@ export class AdminOrderWatcherService {
// acknowledged so a fresh admin session doesn't see the whole history as unread. // acknowledged so a fresh admin session doesn't see the whole history as unread.
if (this.lastAcknowledgedOrderIdSignal() === null) { if (this.lastAcknowledgedOrderIdSignal() === null) {
this.lastAcknowledgedOrderIdSignal.set(items[0].id); this.lastAcknowledgedOrderIdSignal.set(items[0].id);
this.lastAcknowledgedOrderCreatedAtSignal.set(items[0].createdAt);
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, items[0].id); this.storage.setItem(LAST_ACKNOWLEDGED_KEY, items[0].id);
this.storage.setItem(LAST_ACKNOWLEDGED_AT_KEY, items[0].createdAt);
} }
return; return;
} }