# Admin Purchase Notifications Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Notify admin (toast + topbar bell badge/panel) when a new order lands on the marketplace, poll-based since the backend has no WebSocket/SSE. **Architecture:** A single `AdminOrderWatcherService` polls `AdminOrdersLocalGateway.loadOrders()` on an editable interval (default 15s), diffs against a persisted "last notified" order id to fire toasts for genuinely new orders, and exposes a `recentOrders`/`unreadCount` signal pair that the existing (currently-empty) topbar bell panel renders. Poll interval is editable in the admin settings page, same pattern as the currency-rates section added previously. **Tech Stack:** Angular 17+ signals, RxJS, Jasmine/Karma (`ng test`), existing `LocalStorageService`/`UserNotificationService`/`TranslateService` patterns. ## Global Constraints - No WebSocket/SSE available — polling only (confirmed `BACKEND-API-REFERENCE.md:20`). - Persist state via `LocalStorageService` (`getItem`/`setItem`), never raw `localStorage`. - Reuse the existing topbar bell (`admin-layout.component.html:141-157`) instead of adding a new nav badge. - Admin backoffice price/amount displays stay in the order's raw stored currency (no `currencyConvert` pipe) — consistent with every other admin screen. - Route arrays for admin navigation use the pattern `[languageService.currentLanguage(), 'backoffice', 'orders', id]` (no leading `/`), matching `admin-orders-list-page.component.ts:52`. --- ### Task 1: `UserNotificationService` gains an optional click-to-navigate route **Files:** - Modify: `src/app/features/website/user-experience/services/user-notification.service.ts` - Modify: `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts` - Modify: `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html` - Test: `src/app/features/website/user-experience/services/user-notification.service.spec.ts` (new) **Interfaces:** - Produces: `UserNotificationService.show(message: string, type?: UserNotificationType, durationMs?: number, route?: string[]): void` - Produces: `UserNotification.route?: string[]` - [ ] **Step 1: Write the failing test** Create `src/app/features/website/user-experience/services/user-notification.service.spec.ts`: ```typescript import { TestBed } from '@angular/core/testing'; import { UserNotificationService } from './user-notification.service'; describe('UserNotificationService', () => { let service: UserNotificationService; beforeEach(() => { TestBed.configureTestingModule({}); service = TestBed.inject(UserNotificationService); }); it('stores the route on the notification when provided', () => { service.show('New order #1042', 'info', 4000, ['en', 'backoffice', 'orders', 'ord_1']); const [note] = service.notifications(); expect(note.message).toBe('New order #1042'); expect(note.route).toEqual(['en', 'backoffice', 'orders', 'ord_1']); }); it('leaves route undefined when not provided', () => { service.show('Saved'); const [note] = service.notifications(); expect(note.route).toBeUndefined(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm run test -- --include='**/user-notification.service.spec.ts'` Expected: FAIL — `show` has no fourth parameter, `route` does not exist on `UserNotification`. - [ ] **Step 3: Implement** Replace the full contents of `src/app/features/website/user-experience/services/user-notification.service.ts`: ```typescript import { Injectable, signal } from '@angular/core'; export type UserNotificationType = 'success' | 'info' | 'warning'; export interface UserNotification { id: string; message: string; type: UserNotificationType; /** Route to navigate to when the notification is clicked. Absent means not clickable. */ route?: string[]; } @Injectable({ providedIn: 'root' }) export class UserNotificationService { private readonly state = signal([]); readonly notifications = this.state.asReadonly(); show(message: string, type: UserNotificationType = 'info', durationMs: number = 2500, route?: string[]): void { const next: UserNotification = { id: `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, message, type, ...(route ? { route } : {}), }; this.state.update(items => [next, ...items].slice(0, 4)); setTimeout(() => this.dismiss(next.id), durationMs); } dismiss(id: string): void { this.state.update(items => items.filter(item => item.id !== id)); } } ``` Modify `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts` — replace full contents: ```typescript import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { Router } from '@angular/router'; import { UserNotification, UserNotificationService } from '../../services/user-notification.service'; import { TranslatePipe } from '../../../../../i18n/translate.pipe'; @Component({ selector: 'app-floating-notifications', standalone: true, imports: [TranslatePipe], templateUrl: './floating-notifications.component.html', styleUrls: ['./floating-notifications.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class FloatingNotificationsComponent { private readonly notificationsService = inject(UserNotificationService); private readonly router = inject(Router); readonly notifications = this.notificationsService.notifications; dismiss(id: string): void { this.notificationsService.dismiss(id); } navigate(note: UserNotification): void { if (note.route) { void this.router.navigate(note.route); } this.dismiss(note.id); } } ``` Replace full contents of `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html`: ```html @if (notifications().length > 0) { } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm run test -- --include='**/user-notification.service.spec.ts'` Expected: PASS (2 specs) - [ ] **Step 5: Commit** ```bash git add src/app/features/website/user-experience/services/user-notification.service.ts src/app/features/website/user-experience/services/user-notification.service.spec.ts src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html git commit -m "feat: UserNotificationService supports click-to-navigate toasts" ``` --- ### Task 2: `AdminOrderWatcherService` — polling, diffing, toast firing **Files:** - Create: `src/app/features/admin/shell/services/admin-order-watcher.service.ts` - Test: `src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts` - Modify: `src/app/i18n/translations.ts`, `src/app/i18n/en.ts`, `src/app/i18n/ru.ts`, `src/app/i18n/hy.ts` (one new key) **Interfaces:** - Consumes: `AdminOrdersLocalGateway.loadOrders(filters: AdminOrderListFilters): Observable` (existing) - Consumes: `UserNotificationService.show(message, type?, durationMs?, route?)` (Task 1) - Consumes: `LocalStorageService.getItem(key): string | null`, `.setItem(key, value): void` (existing) - Produces: `AdminOrderWatcherService.recentOrders: Signal` - Produces: `AdminOrderWatcherService.unreadCount: Signal` - Produces: `AdminOrderWatcherService.intervalMs: Signal` - Produces: `AdminOrderWatcherService.start(): void` - Produces: `AdminOrderWatcherService.markAllSeen(): void` - Produces: `AdminOrderWatcherService.setIntervalSeconds(seconds: number): void` - [ ] **Step 1: Add the i18n key first (needed by the test's translated toast message)** In `src/app/i18n/translations.ts`, inside the `topbar:` block under `adminShell` (next to `notificationsEmpty: string;`): ```typescript notificationsEmpty: string; notificationNewOrder: string; ``` In `src/app/i18n/en.ts`, inside `adminShell.topbar` (next to `notificationsEmpty:`): ```typescript notificationsEmpty: 'No new notifications', notificationNewOrder: 'New order #{{orderNumber}}', ``` In `src/app/i18n/ru.ts`, inside `adminShell.topbar`: ```typescript notificationsEmpty: 'Нет новых уведомлений', notificationNewOrder: 'Новый заказ №{{orderNumber}}', ``` In `src/app/i18n/hy.ts`, inside `adminShell.topbar`: ```typescript notificationsEmpty: 'Նոր ծանուցումներ չկան', notificationNewOrder: 'Նոր պատվեր #{{orderNumber}}', ``` (Match each file's existing `notificationsEmpty` value/indentation exactly — only add the new line after it.) - [ ] **Step 2: Write the failing test** Create `src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts`: ```typescript import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { of } from 'rxjs'; import { AdminOrderWatcherService } from './admin-order-watcher.service'; import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway'; import { AdminOrder, AdminOrdersListResult } from '../../orders/models/admin-order.model'; import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service'; function makeOrder(id: string, orderNumber: string, createdAt: string): AdminOrder { return { id, orderNumber, status: 'pending', customer: { name: 'Test Customer', email: 't@example.com', phone: '+70000000000' }, payment: { method: 'card', status: 'paid', amount: 1000, currency: 'RUB' }, shipping: { address: '', method: '', trackingNumber: '' }, items: [], total: 1000, currency: 'RUB', notes: '', internalNotes: '', timeline: [], archived: false, createdAt, updatedAt: createdAt, }; } describe('AdminOrderWatcherService', () => { let ordersByPoll: AdminOrder[][]; let pollIndex: number; let notifications: UserNotificationService; let service: AdminOrderWatcherService; function fakeGateway() { return { loadOrders: () => { const items = ordersByPoll[pollIndex] ?? ordersByPoll[ordersByPoll.length - 1]; const result: AdminOrdersListResult = { items, total: items.length, page: 1, pageSize: 20 }; return of(result); }, }; } beforeEach(() => { pollIndex = 0; ordersByPoll = [ [makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z')], ]; localStorage.clear(); TestBed.configureTestingModule({ providers: [ provideRouter([]), { provide: AdminOrdersLocalGateway, useValue: fakeGateway() as unknown as AdminOrdersLocalGateway }, ], }); notifications = TestBed.inject(UserNotificationService); service = TestBed.inject(AdminOrderWatcherService); }); it('does not toast on the very first poll and marks everything as acknowledged', fakeAsync(() => { service.start(); tick(0); expect(notifications.notifications().length).toBe(0); expect(service.unreadCount()).toBe(0); expect(service.recentOrders().map(o => o.id)).toEqual(['o2', 'o1']); })); it('toasts and increments unreadCount for orders newer than the last-notified one', fakeAsync(() => { service.start(); tick(0); 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()); expect(notifications.notifications().length).toBe(1); expect(notifications.notifications()[0].message).toContain('1003'); expect(notifications.notifications()[0].route).toEqual(['ru', 'backoffice', 'orders', 'o3']); expect(service.unreadCount()).toBe(1); })); it('markAllSeen resets unreadCount without clearing recentOrders', fakeAsync(() => { service.start(); tick(0); 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()); expect(service.unreadCount()).toBe(1); service.markAllSeen(); expect(service.unreadCount()).toBe(0); expect(service.recentOrders().map(o => o.id)).toEqual(['o3', 'o2', 'o1']); })); it('setIntervalSeconds updates intervalMs and rejects invalid values', () => { service.setIntervalSeconds(30); expect(service.intervalMs()).toBe(30000); service.setIntervalSeconds(0); expect(service.intervalMs()).toBe(30000); service.setIntervalSeconds(-5); expect(service.intervalMs()).toBe(30000); }); }); ``` Note: `LanguageService` defaults to `'ru'` (see `language.service.ts:23`), which is why the expected route in the second test starts with `'ru'`. - [ ] **Step 3: Run test to verify it fails** Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts'` Expected: FAIL — `admin-order-watcher.service.ts` does not exist yet. - [ ] **Step 4: Implement** Create `src/app/features/admin/shell/services/admin-order-watcher.service.ts`: ```typescript import { Injectable, Signal, computed, inject, signal } from '@angular/core'; import { AdminOrder } from '../../orders/models/admin-order.model'; import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway'; import { LocalStorageService } from '../../../../core/storage/local-storage.service'; import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service'; import { LanguageService } from '../../../../services/language.service'; import { TranslateService } from '../../../../i18n/translate.service'; const LAST_NOTIFIED_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1'; const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1'; const POLL_INTERVAL_KEY = 'adminOrderWatcher.pollIntervalMs.v1'; export const DEFAULT_POLL_INTERVAL_MS = 15000; const MIN_POLL_INTERVAL_MS = 1000; const RECENT_ORDERS_LIMIT = 20; const TOAST_DURATION_MS = 4000; @Injectable({ providedIn: 'root' }) export class AdminOrderWatcherService { private readonly gateway = inject(AdminOrdersLocalGateway); private readonly storage = inject(LocalStorageService); private readonly notifications = inject(UserNotificationService); private readonly languageService = inject(LanguageService); private readonly i18n = inject(TranslateService); private readonly recentOrdersSignal = signal([]); readonly recentOrders: Signal = this.recentOrdersSignal.asReadonly(); private readonly lastAcknowledgedOrderIdSignal = signal(this.storage.getItem(LAST_ACKNOWLEDGED_KEY)); readonly unreadCount = computed(() => { const orders = this.recentOrdersSignal(); if (orders.length === 0) { return 0; } const ackId = this.lastAcknowledgedOrderIdSignal(); if (ackId === null) { return orders.length; } const idx = orders.findIndex(order => order.id === ackId); return idx === -1 ? orders.length : idx; }); private readonly intervalMsSignal = signal(this.readStoredIntervalMs()); readonly intervalMs: Signal = this.intervalMsSignal.asReadonly(); private lastNotifiedOrderId: string | null = this.storage.getItem(LAST_NOTIFIED_KEY); private timerId: ReturnType | null = null; private started = false; start(): void { if (this.started) { return; } this.started = true; this.poll(); this.scheduleNext(); } setIntervalSeconds(seconds: number): void { if (!Number.isFinite(seconds) || seconds < MIN_POLL_INTERVAL_MS / 1000) { return; } const ms = Math.round(seconds * 1000); this.intervalMsSignal.set(ms); this.storage.setItem(POLL_INTERVAL_KEY, String(ms)); if (this.started) { this.scheduleNext(); } } markAllSeen(): void { const newestId = this.recentOrdersSignal()[0]?.id ?? null; this.lastAcknowledgedOrderIdSignal.set(newestId); if (newestId) { this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newestId); } } private scheduleNext(): void { if (this.timerId !== null) { clearInterval(this.timerId); } this.timerId = setInterval(() => this.poll(), this.intervalMsSignal()); } private poll(): void { this.gateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: RECENT_ORDERS_LIMIT }).subscribe({ next: result => this.handleOrders(result.items), error: err => console.error('Error polling for new orders:', err), }); } private handleOrders(items: AdminOrder[]): void { this.recentOrdersSignal.set(items); if (items.length === 0) { return; } const isFirstPoll = this.lastNotifiedOrderId === null; const notifyIndex = isFirstPoll ? -1 : items.findIndex(order => order.id === this.lastNotifiedOrderId); const newOrders = isFirstPoll ? [] : (notifyIndex === -1 ? items : items.slice(0, notifyIndex)); this.lastNotifiedOrderId = items[0].id; this.storage.setItem(LAST_NOTIFIED_KEY, this.lastNotifiedOrderId); if (isFirstPoll) { // Nothing existed to compare against yet - treat current orders as already // acknowledged so a fresh admin session doesn't see the whole history as unread. if (this.lastAcknowledgedOrderIdSignal() === null) { this.lastAcknowledgedOrderIdSignal.set(items[0].id); this.storage.setItem(LAST_ACKNOWLEDGED_KEY, items[0].id); } return; } for (let i = newOrders.length - 1; i >= 0; i--) { const order = newOrders[i]; this.notifications.show( this.i18n.t('adminShell.topbar.notificationNewOrder', { orderNumber: order.orderNumber }), 'info', TOAST_DURATION_MS, [this.languageService.currentLanguage(), 'backoffice', 'orders', order.id] ); } } private readStoredIntervalMs(): number { const stored = Number(this.storage.getItem(POLL_INTERVAL_KEY)); return Number.isFinite(stored) && stored >= MIN_POLL_INTERVAL_MS ? stored : DEFAULT_POLL_INTERVAL_MS; } } ``` - [ ] **Step 5: Run test to verify it passes** Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts'` Expected: PASS (4 specs) - [ ] **Step 6: Commit** ```bash git add src/app/features/admin/shell/services/admin-order-watcher.service.ts src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts git commit -m "feat: AdminOrderWatcherService polls for new orders and toasts" ``` --- ### Task 3: Wire the watcher into the admin topbar bell **Files:** - Modify: `src/app/features/admin/shell/admin-layout.component.ts` - Modify: `src/app/features/admin/shell/admin-layout.component.html` - Modify: `src/app/features/admin/shell/admin-layout.component.scss` - Test: `src/app/features/admin/shell/admin-layout.component.spec.ts` (new) **Interfaces:** - Consumes: `AdminOrderWatcherService.{recentOrders, unreadCount, start, markAllSeen}` (Task 2) - [ ] **Step 1: Write the failing test** Create `src/app/features/admin/shell/admin-layout.component.spec.ts`: ```typescript import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { signal } from '@angular/core'; import { AdminLayoutComponent } from './admin-layout.component'; import { AdminOrderWatcherService } from './services/admin-order-watcher.service'; import { AdminOrder } from '../orders/models/admin-order.model'; function makeOrder(id: string, orderNumber: string): AdminOrder { return { id, orderNumber, status: 'pending', customer: { name: 'Test Customer', email: 't@example.com', phone: '' }, payment: { method: 'card', status: 'paid', amount: 500, currency: 'RUB' }, shipping: { address: '', method: '', trackingNumber: '' }, items: [], total: 500, currency: 'RUB', notes: '', internalNotes: '', timeline: [], archived: false, createdAt: '2026-08-15T10:00:00.000Z', updatedAt: '2026-08-15T10:00:00.000Z', }; } describe('AdminLayoutComponent notifications bell', () => { let watcherStub: { recentOrders: ReturnType>; unreadCount: ReturnType>; start: jasmine.Spy; markAllSeen: jasmine.Spy; }; beforeEach(() => { watcherStub = { recentOrders: signal([makeOrder('o1', '1001')]), unreadCount: signal(1), start: jasmine.createSpy('start'), markAllSeen: jasmine.createSpy('markAllSeen'), }; TestBed.configureTestingModule({ imports: [AdminLayoutComponent], providers: [ provideRouter([]), { provide: AdminOrderWatcherService, useValue: watcherStub }, ], }); }); it('starts the watcher once on construction', () => { TestBed.createComponent(AdminLayoutComponent); expect(watcherStub.start).toHaveBeenCalledTimes(1); }); it('exposes unreadCount and recentOrders from the watcher', () => { const fixture = TestBed.createComponent(AdminLayoutComponent); const component = fixture.componentInstance; expect(component.unreadCount()).toBe(1); expect(component.recentOrders().map(o => o.id)).toEqual(['o1']); }); it('marks orders seen when the notifications panel opens', () => { const fixture = TestBed.createComponent(AdminLayoutComponent); const component = fixture.componentInstance; component.toggleNotifications(); expect(component.notificationsOpen()).toBe(true); expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1); component.toggleNotifications(); expect(component.notificationsOpen()).toBe(false); expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm run test -- --include='**/admin-layout.component.spec.ts'` Expected: FAIL — `AdminOrderWatcherService` not referenced by the component yet, `unreadCount`/`recentOrders` don't exist on `AdminLayoutComponent`. - [ ] **Step 3: Implement** In `src/app/features/admin/shell/admin-layout.component.ts`, add the import and field (place near the other service injections): ```typescript import { AdminOrderWatcherService } from './services/admin-order-watcher.service'; ``` ```typescript private readonly orderWatcher = inject(AdminOrderWatcherService); readonly unreadCount = this.orderWatcher.unreadCount; readonly recentOrders = this.orderWatcher.recentOrders; ``` In the constructor, after `this.readRouteData();`, add: ```typescript this.orderWatcher.start(); ``` Replace the `toggleNotifications` method: ```typescript toggleNotifications(): void { this.notificationsOpen.update(open => !open); if (this.notificationsOpen()) { this.orderWatcher.markAllSeen(); } } ``` Add a navigation helper next to `adminLinkFor`: ```typescript goToOrder(orderId: string): void { void this.router.navigate([this.currentLang(), 'backoffice', 'orders', orderId]); this.notificationsOpen.set(false); } ``` In `src/app/features/admin/shell/admin-layout.component.html`, replace the notifications block (lines 141-157): ```html
@if (notificationsOpen()) { }
``` In `src/app/features/admin/shell/admin-layout.component.scss`, add (near other `.admin-layout__notifications*` rules if any exist, otherwise at the end): ```scss .admin-layout__notifications { position: relative; } .admin-layout__notifications-badge { position: absolute; top: 2px; right: 2px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: var(--color-danger, #ef4444); color: #fff; font-size: 10px; line-height: 16px; text-align: center; } .admin-layout__notification-item { display: flex; flex-direction: column; gap: 2px; width: 100%; padding: 8px 10px; border: none; background: none; text-align: left; cursor: pointer; border-radius: var(--radius-sm, 4px); } .admin-layout__notification-item:hover { background: var(--bg-secondary, #f4f6f5); } .admin-layout__notification-order { font-weight: var(--font-weight-medium, 500); } .admin-layout__notification-customer { color: var(--text-secondary, #6b7280); font-size: var(--font-size-sm, 0.8125rem); } .admin-layout__notification-amount { font-size: var(--font-size-sm, 0.8125rem); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm run test -- --include='**/admin-layout.component.spec.ts'` Expected: PASS (3 specs) - [ ] **Step 5: Run full build to catch template errors** Run: `npx ng build --configuration development` Expected: build succeeds, no template compile errors. - [ ] **Step 6: Commit** ```bash git add src/app/features/admin/shell/admin-layout.component.ts src/app/features/admin/shell/admin-layout.component.html src/app/features/admin/shell/admin-layout.component.scss src/app/features/admin/shell/admin-layout.component.spec.ts git commit -m "feat: wire order watcher into admin topbar bell (badge + panel)" ``` --- ### Task 4: Editable poll interval in admin settings **Files:** - Modify: `src/app/features/admin/settings/pages/admin-settings-page.component.ts` - Modify: `src/app/features/admin/settings/pages/admin-settings-page.component.html` - Modify: `src/app/i18n/translations.ts`, `src/app/i18n/en.ts`, `src/app/i18n/ru.ts`, `src/app/i18n/hy.ts` - Test: `src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts` (new) **Interfaces:** - Consumes: `AdminOrderWatcherService.{intervalMs, setIntervalSeconds}` (Task 2) - [ ] **Step 1: Add i18n keys** In `src/app/i18n/translations.ts`, inside `adminSettings:` (after `currencyRatesSaved: string;`): ```typescript notificationInterval: string; notificationIntervalExplain: string; notificationIntervalSave: string; notificationIntervalSaved: string; ``` In `src/app/i18n/en.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Rates saved',`): ```typescript notificationInterval: 'New-order check interval (seconds)', notificationIntervalExplain: 'How often the admin panel polls for new orders to show a notification.', notificationIntervalSave: 'Save interval', notificationIntervalSaved: 'Interval saved', ``` In `src/app/i18n/ru.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Курсы сохранены',`): ```typescript notificationInterval: 'Интервал проверки новых заказов (сек)', notificationIntervalExplain: 'Как часто админ-панель проверяет новые заказы для уведомления.', notificationIntervalSave: 'Сохранить интервал', notificationIntervalSaved: 'Интервал сохранён', ``` In `src/app/i18n/hy.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Փոխարժեքները պահպանվեցին',`): ```typescript notificationInterval: 'Նոր պատվերների ստուգման ինտերվալ (վրկ)', notificationIntervalExplain: 'Որքան հաճախ է ադմին վահանակը ստուգում նոր պատվերներ ծանուցման համար։', notificationIntervalSave: 'Պահպանել ինտերվալը', notificationIntervalSaved: 'Ինտերվալը պահպանվեց', ``` - [ ] **Step 2: Write the failing test** Create `src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts`: ```typescript import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { signal } from '@angular/core'; import { AdminSettingsPageComponent } from './admin-settings-page.component'; import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service'; describe('AdminSettingsPageComponent notification interval', () => { let watcherStub: { intervalMs: ReturnType>; setIntervalSeconds: jasmine.Spy; }; beforeEach(() => { watcherStub = { intervalMs: signal(15000), setIntervalSeconds: jasmine.createSpy('setIntervalSeconds'), }; TestBed.configureTestingModule({ imports: [AdminSettingsPageComponent], providers: [ provideRouter([]), { provide: AdminOrderWatcherService, useValue: watcherStub }, ], }); }); it('initializes the draft from the current interval in seconds', () => { const fixture = TestBed.createComponent(AdminSettingsPageComponent); expect(fixture.componentInstance.notificationIntervalSecondsDraft()).toBe(15); }); it('saveNotificationInterval calls setIntervalSeconds with the draft value', () => { const fixture = TestBed.createComponent(AdminSettingsPageComponent); const component = fixture.componentInstance; component.notificationIntervalSecondsDraft.set(30); component.saveNotificationInterval(); expect(watcherStub.setIntervalSeconds).toHaveBeenCalledWith(30); }); }); ``` - [ ] **Step 3: Run test to verify it fails** Run: `npm run test -- --include='**/admin-settings-page.component.spec.ts'` Expected: FAIL — `notificationIntervalSecondsDraft`/`saveNotificationInterval` don't exist yet. - [ ] **Step 4: Implement** In `src/app/features/admin/settings/pages/admin-settings-page.component.ts`, add the import: ```typescript import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service'; ``` Add the field and methods to the class (alongside the currency-rates fields): ```typescript readonly orderWatcher = inject(AdminOrderWatcherService); readonly notificationIntervalSecondsDraft = signal(Math.round(this.orderWatcher.intervalMs() / 1000)); readonly showNotificationIntervalSaved = signal(false); saveNotificationInterval(): void { this.orderWatcher.setIntervalSeconds(this.notificationIntervalSecondsDraft()); this.showNotificationIntervalSaved.set(true); setTimeout(() => this.showNotificationIntervalSaved.set(false), SAVED_MESSAGE_DURATION_MS); } ``` In `src/app/features/admin/settings/pages/admin-settings-page.component.html`, add a new `.settings-card` block after the currency-rates one (before the closing ``): ```html

{{ 'adminSettings.notificationInterval' | translate }}

{{ 'adminSettings.notificationIntervalExplain' | translate }}

{{ 'adminSettings.notificationIntervalSaved' | translate }}
``` - [ ] **Step 5: Run test to verify it passes** Run: `npm run test -- --include='**/admin-settings-page.component.spec.ts'` Expected: PASS (2 specs) - [ ] **Step 6: Full verification** Run: `npx tsc --noEmit -p tsconfig.json` Expected: no errors. Run: `npx ng build --configuration development` Expected: build succeeds. Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts' --include='**/admin-layout.component.spec.ts' --include='**/admin-settings-page.component.spec.ts' --include='**/user-notification.service.spec.ts'` Expected: all specs PASS. - [ ] **Step 7: Commit** ```bash git add src/app/features/admin/settings/pages/admin-settings-page.component.ts src/app/features/admin/settings/pages/admin-settings-page.component.html src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts git commit -m "feat: editable new-order poll interval in admin settings" ``` --- ## Manual Verification (after all tasks) 1. `npm run barry -- kb search --source cq --query "admin order notifications"` if KB sharing is enabled (skip if local-only — see project `CLAUDE.md`). 2. Start the dev server, log into `/backoffice`, leave the tab open. 3. In another tab (or via `admin-orders-local.gateway.ts`'s seed data timing), wait for the poll interval — confirm no toast fires on first load. 4. Trigger a new order (checkout flow in `cart.component.ts`, or temporarily lower `SEED_COUNT`/seed timing in `admin-orders-local.gateway.ts` to simulate one — revert after testing) and confirm: toast appears with the order number, bell badge shows `1`, clicking either navigates to `/backoffice/orders/:id`. 5. Open the bell panel without clicking a row — confirm badge clears but the order still lists in the panel. 6. Change the interval in Admin Settings, save, confirm the toast "Interval saved" message. no crash on next poll cycle.