From 3212d6c185faf638f46d4230fdbad9f5695aaa7e Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 18:18:54 +0500 Subject: [PATCH] docs: SSE realtime implementation plan --- .../plans/2026-05-22-sse-realtime.md | 824 ++++++++++++++++++ 1 file changed, 824 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-22-sse-realtime.md diff --git a/docs/superpowers/plans/2026-05-22-sse-realtime.md b/docs/superpowers/plans/2026-05-22-sse-realtime.md new file mode 100644 index 0000000..4659666 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-sse-realtime.md @@ -0,0 +1,824 @@ +# SSE Realtime 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:** Replace HTTP polling with Server-Sent Events (SSE) for real-time updates of chat messages, unread counters, order status changes, and admin notifications. + +**Architecture:** New SSE route on server bridges existing EventBus events to SSE streams. Client-side SseProvider manages EventSource lifecycle (connect on login, close on logout) and invalidates React Query caches on incoming events. + +**Tech Stack:** Fastify (raw SSE via `reply.raw`), EventSource API, Effector (`$token` store), React Query (`invalidateQueries`), vitest. + +--- + +## File Map + +| File | Responsibility | +|---|---| +| `server/src/routes/sse.js` | SSE endpoint, EventBus listener bridge | +| `server/src/routes/__tests__/sse.test.js` | Server tests | +| `server/src/index.js` | Import and register SSE routes | +| `client/src/shared/lib/sse.ts` | EventSource factory | +| `client/src/app/providers/SseProvider.tsx` | SSE→ReactQuery invalidation bridge | +| `client/src/app/providers/__tests__/SseProvider.test.tsx` | Client tests | +| `client/src/app/providers/AppProviders.tsx` | Mount SseProvider | +| `client/src/pages/me/ui/MeLayoutPage.tsx` | Remove refetchInterval | +| `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx` | Remove refetchInterval | + +--- + +### Task 1: Server — write SSE route tests (TDD red) + +**Files:** +- Create: `server/src/routes/__tests__/sse.test.js` + +- [ ] **Step 1: Write the test file** + +```js +import Fastify from 'fastify' +import { EventEmitter } from 'node:events' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js' + +describe('formatSSE', () => { + it('formats event with data', () => { + const result = formatSSE('message:new', { orderId: 'o1' }) + expect(result).toBe('event: message:new\ndata: {"orderId":"o1"}\n\n') + }) + + it('formats event without data', () => { + const result = formatSSE('heartbit') + expect(result).toBe('event: heartbit\n\n') + }) +}) + +describe('formatHeartbit', () => { + it('returns SSE comment', () => { + expect(formatHeartbit()).toBe(':heartbit\n\n') + }) +}) + +describe('isAdminUser', () => { + it('returns false for non-matching email', () => { + expect(isAdminUser({ email: 'user@test.com' })).toBe(false) + }) + + it('returns true when email matches ADMIN_EMAIL', () => { + const adminEmail = process.env.ADMIN_EMAIL + if (adminEmail) { + expect(isAdminUser({ email: adminEmail })).toBe(true) + } + }) + + it('returns false for null/undefined user', () => { + expect(isAdminUser(null)).toBe(false) + expect(isAdminUser(undefined)).toBe(false) + }) +}) + +describe('buildSseListeners', () => { + let eventBus + let write + + beforeEach(() => { + eventBus = new EventEmitter() + eventBus.setMaxListeners(50) + write = vi.fn() + }) + + it('forwards orderMessage:adminReply to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: message:new') + expect(write.mock.calls[0][0]).toContain('"orderId":"o1"') + cleanup() + }) + + it('ignores orderMessage:adminReply for non-matching userId', () => { + const cleanup = buildSseListeners('user-2', false, eventBus, write) + eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' }) + expect(write).not.toHaveBeenCalled() + cleanup() + }) + + it('forwards orderMessage:sent to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: message:new') + cleanup() + }) + + it('ignores orderMessage:sent for non-admin', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' }) + expect(write).not.toHaveBeenCalled() + cleanup() + }) + + it('forwards order:statusChanged to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('order:statusChanged', { orderId: 'o1', userId: 'user-1', oldStatus: 'PENDING_PAYMENT', newStatus: 'PAID' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:statusChanged') + expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"') + cleanup() + }) + + it('forwards payment:statusChanged to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('payment:statusChanged', { orderId: 'o1', userId: 'user-1', paymentStatus: 'paid' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:statusChanged') + cleanup() + }) + + it('forwards order:deliveryFeeAdjusted to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('order:deliveryFeeAdjusted', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:updated') + cleanup() + }) + + it('forwards order:created to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:new') + cleanup() + }) + + it('forwards order:created:admin to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('order:created:admin', { orderId: 'o1', userId: 'user-1', userEmail: 'user@test.com' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:new') + cleanup() + }) + + it('ignores order:created for non-admin', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) + expect(write).not.toHaveBeenCalled() + cleanup() + }) + + it('cleanup removes all listeners', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + cleanup() + eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' }) + expect(write).not.toHaveBeenCalled() + }) +}) + +describe('GET /api/sse/stream (integration)', () => { + let app + + beforeAll(async () => { + app = Fastify({ logger: false }) + app.decorate('authenticate', async function (request, reply) { + try { + const token = request.query?.token + if (!token) throw new Error('no token') + if (token === 'user-token') { + request.user = { sub: 'user-1', email: 'user@test.com' } + } else if (token === 'admin-token') { + request.user = { sub: 'admin-1', email: process.env.ADMIN_EMAIL || 'admin@test.com' } + } else { + throw new Error('bad token') + } + } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + app.decorate('eventBus', new EventEmitter()) + await registerSseRoutes(app) + await app.ready() + }) + + afterAll(async () => { + await app.close() + }) + + it('returns 401 without token', async () => { + const res = await app.inject({ method: 'GET', url: '/api/sse/stream' }) + expect(res.statusCode).toBe(401) + }) + + it('returns 200 and event-stream headers for authenticated user', async () => { + const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token' }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toBe('text/event-stream') + expect(res.headers['cache-control']).toBe('no-cache') + expect(res.headers['connection']).toBe('keep-alive') + }) + + it('sends initial heartbit', async () => { + const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token' }) + expect(res.body).toContain(':heartbit') + }) +}) +``` + +- [ ] **Step 2: Run tests — expect FAIL (sse.js does not exist)** + +Run: `cd server && npx vitest run src/routes/__tests__/sse.test.js` +Expected: FAIL — module `../sse.js` not found + +--- + +### Task 2: Server — implement SSE route (TDD green) + +**Files:** +- Create: `server/src/routes/sse.js` + +- [ ] **Step 1: Write sse.js with exported helpers** + +```js +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' + +const { + ORDER_CREATED, + ORDER_STATUS_CHANGED, + ORDER_MESSAGE_SENT, + ORDER_MESSAGE_ADMIN_REPLY, + PAYMENT_STATUS_CHANGED, + DELIVERY_FEE_ADJUSTED, +} = NOTIFICATION_EVENTS + +export function isAdminUser(user) { + return user?.email === process.env.ADMIN_EMAIL +} + +export function formatSSE(event, data) { + const lines = [`event: ${event}`] + if (data !== undefined) { + lines.push(`data: ${JSON.stringify(data)}`) + } + return lines.join('\n') + '\n\n' +} + +export function formatHeartbit() { + return ':heartbit\n\n' +} + +export function buildSseListeners(userId, admin, eventBus, write) { + const listeners = [] + + function on(eventName, filterFn, sseEvent, dataFn) { + function handler(payload) { + if (!filterFn(payload)) return + write(formatSSE(sseEvent, dataFn(payload))) + } + listeners.push({ eventName, handler }) + eventBus.on(eventName, handler) + } + + on( + ORDER_MESSAGE_ADMIN_REPLY, + (p) => p.userId === userId, + 'message:new', + (p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }), + ) + + on( + ORDER_MESSAGE_SENT, + () => admin, + 'message:new', + (p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }), + ) + + on( + ORDER_STATUS_CHANGED, + (p) => p.userId === userId, + 'order:statusChanged', + (p) => ({ orderId: p.orderId, newStatus: p.newStatus }), + ) + + on( + PAYMENT_STATUS_CHANGED, + (p) => p.userId === userId, + 'order:statusChanged', + (p) => ({ orderId: p.orderId }), + ) + + on( + DELIVERY_FEE_ADJUSTED, + (p) => p.userId === userId, + 'order:updated', + (p) => ({ orderId: p.orderId }), + ) + + on( + ORDER_CREATED, + () => admin, + 'order:new', + (p) => ({ orderId: p.orderId }), + ) + + on( + 'order:created:admin', + () => admin, + 'order:new', + (p) => ({ orderId: p.orderId }), + ) + + return function cleanup() { + for (const { eventName, handler } of listeners) { + eventBus.off(eventName, handler) + } + } +} + +export async function registerSseRoutes(fastify) { + fastify.get('/api/sse/stream', { preHandler: [fastify.authenticate] }, async (request, reply) => { + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }) + + const userId = request.user.sub + const admin = isAdminUser(request.user) + + reply.raw.write(formatHeartbit()) + + const heartbitTimer = setInterval(() => { + reply.raw.write(formatHeartbit()) + }, 30_000) + + const cleanup = buildSseListeners(userId, admin, fastify.eventBus, (chunk) => { + reply.raw.write(chunk) + }) + + request.raw.on('close', () => { + clearInterval(heartbitTimer) + cleanup() + }) + }) +} +``` + +- [ ] **Step 2: Run tests — expect PASS** + +Run: `cd server && npx vitest run src/routes/__tests__/sse.test.js` +Expected: all PASS + +- [ ] **Step 3: Commit both files** + +```bash +git add server/src/routes/sse.js server/src/routes/__tests__/sse.test.js +git commit -m "feat: add SSE route with EventBus bridge and tests" +``` + +--- + +### Task 3: Server — register SSE routes in index.js + +**Files:** +- Modify: `server/src/index.js` + +- [ ] **Step 1: Add import** + +After line 27 (`import { registerUserMessageRoutes } from './routes/user-messages.js'`), add: + +```js +import { registerSseRoutes } from './routes/sse.js' +``` + +- [ ] **Step 2: Add registration** + +After line 94 (`await registerUserMessageRoutes(fastify)`), add: + +```js +await registerSseRoutes(fastify) +``` + +- [ ] **Step 3: Verify server starts** + +Run: `cd server && timeout 5 npm run dev 2>&1 || true` +Expected: no crash, server starts listening + +- [ ] **Step 4: Commit** + +```bash +git add server/src/index.js +git commit -m "feat: register SSE routes in server" +``` + +--- + +### Task 4: Client — EventSource factory + +**Files:** +- Create: `client/src/shared/lib/sse.ts` + +- [ ] **Step 1: Write the factory** + +```ts +export function createEventStream(token: string): EventSource { + return new EventSource(`/api/sse/stream?token=${encodeURIComponent(token)}`) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add client/src/shared/lib/sse.ts +git commit -m "feat: add EventSource factory for SSE" +``` + +--- + +### Task 5: Client — write SseProvider tests (TDD red) + +**Files:** +- Create: `client/src/app/providers/__tests__/SseProvider.test.tsx` + +- [ ] **Step 1: Write the test file** + +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SseProvider } from '../SseProvider' + +const mockInvalidateQueries = vi.fn() + +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query') + return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) } +}) + +vi.mock('@/shared/model/auth', () => ({ + $token: { defaultState: null, subscribe: () => () => {}, getState: () => null, watch: () => () => {}, on: () => {}, reset: () => {} }, +})) + +let mockToken: string | null = null +let mockEventHandlers: Record void> = {} +let mockCloseCalls = 0 + +class MockEventSource { + url: string + constructor(url: string) { + this.url = url + mockCloseCalls = 0 + mockEventHandlers = {} + } + addEventListener(type: string, handler: (event: MessageEvent) => void) { + mockEventHandlers[type] = handler + } + removeEventListener(type: string, _handler: (event: MessageEvent) => void) { + delete mockEventHandlers[type] + } + close() { mockCloseCalls++ } +} + +vi.mock('@/shared/lib/sse', () => ({ + createEventStream: (token: string) => { + mockToken = token + return new MockEventSource(`/api/sse/stream?token=${token}`) as unknown as EventSource + }, +})) + +vi.mock('effector-react', async () => { + const actual = await vi.importActual('effector-react') + return { ...actual, useUnit: () => mockToken } +}) + +function renderSse() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render() +} + +describe('SseProvider', () => { + afterEach(() => { + mockToken = null + mockInvalidateQueries.mockReset() + mockCloseCalls = 0 + mockEventHandlers = {} + }) + + it('renders nothing (returns null)', () => { + mockToken = null + const { container } = renderSse() + expect(container.innerHTML).toBe('') + }) + + it('does not create EventSource when token is null', () => { + mockToken = null + renderSse() + expect(mockToken).toBeNull() + }) + + it('creates EventSource when token is set', () => { + mockToken = 'test-jwt' + renderSse() + expect(mockToken).toBe('test-jwt') + }) + + it('closes EventSource on unmount', () => { + mockToken = 'test-jwt' + const { unmount } = renderSse() + expect(mockCloseCalls).toBe(0) + unmount() + expect(mockCloseCalls).toBe(1) + }) + + it('invalidates unread-count and conversations on message:new', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['message:new'] + expect(handler).toBeDefined() + handler(new MessageEvent('message:new', { data: JSON.stringify({ orderId: 'o1' }) })) + + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o1'] }) + }) + + it('invalidates order queries on order:statusChanged', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['order:statusChanged'] + handler(new MessageEvent('order:statusChanged', { data: JSON.stringify({ orderId: 'o2' }) })) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o2'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o2'] }) + }) + + it('invalidates order queries on order:updated', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['order:updated'] + handler(new MessageEvent('order:updated', { data: JSON.stringify({ orderId: 'o3' }) })) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o3'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o3'] }) + }) + + it('invalidates admin queries on order:new', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['order:new'] + handler(new MessageEvent('order:new', { data: JSON.stringify({ orderId: 'o4' }) })) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] }) + }) + + it('handles invalid JSON gracefully', () => { + mockToken = 'test-jwt' + renderSse() + const handler = mockEventHandlers['message:new'] + expect(() => { handler(new MessageEvent('message:new', { data: ':heartbit' })) }).not.toThrow() + expect(mockInvalidateQueries).not.toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run tests — expect FAIL (SseProvider does not exist)** + +Run: `cd client && npx vitest run src/app/providers/__tests__/SseProvider.test.tsx` +Expected: FAIL — module `../SseProvider` not found + +--- + +### Task 6: Client — implement SseProvider (TDD green) + +**Files:** +- Create: `client/src/app/providers/SseProvider.tsx` + +- [ ] **Step 1: Write the SseProvider component** + +```tsx +import { useEffect, useRef } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useUnit } from 'effector-react' +import { createEventStream } from '@/shared/lib/sse' +import { $token } from '@/shared/model/auth' + +export function SseProvider() { + const token = useUnit($token) + const queryClient = useQueryClient() + const sourceRef = useRef(null) + + useEffect(() => { + if (!token) { + if (sourceRef.current) { + sourceRef.current.close() + sourceRef.current = null + } + return + } + + const es = createEventStream(token) + sourceRef.current = es + + function handleEvent(eventName: string) { + return function (event: MessageEvent) { + try { + const data = JSON.parse(event.data) + const orderId = data.orderId + + switch (eventName) { + case 'message:new': + queryClient.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] }) + queryClient.invalidateQueries({ queryKey: ['me', 'conversations'] }) + if (orderId) { + queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] }) + } + break + case 'order:statusChanged': + if (orderId) { + queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] }) + } + break + case 'order:updated': + if (orderId) { + queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] }) + } + break + case 'order:new': + queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] }) + break + } + } catch { + // ignore parse errors (e.g. heartbit comments) + } + } + } + + const messageNewHandler = handleEvent('message:new') + const orderStatusHandler = handleEvent('order:statusChanged') + const orderUpdatedHandler = handleEvent('order:updated') + const orderNewHandler = handleEvent('order:new') + + es.addEventListener('message:new', messageNewHandler) + es.addEventListener('order:statusChanged', orderStatusHandler) + es.addEventListener('order:updated', orderUpdatedHandler) + es.addEventListener('order:new', orderNewHandler) + + return () => { + es.removeEventListener('message:new', messageNewHandler) + es.removeEventListener('order:statusChanged', orderStatusHandler) + es.removeEventListener('order:updated', orderUpdatedHandler) + es.removeEventListener('order:new', orderNewHandler) + es.close() + sourceRef.current = null + } + }, [token, queryClient]) + + return null +} +``` + +- [ ] **Step 2: Run tests — expect PASS** + +Run: `cd client && npx vitest run src/app/providers/__tests__/SseProvider.test.tsx` +Expected: all PASS + +- [ ] **Step 3: Commit both files** + +```bash +git add client/src/shared/lib/sse.ts client/src/app/providers/SseProvider.tsx client/src/app/providers/__tests__/SseProvider.test.tsx +git commit -m "feat: add SseProvider — SSE to ReactQuery bridge with tests" +``` + +--- + +### Task 7: Client — mount SseProvider in AppProviders + +**Files:** +- Modify: `client/src/app/providers/AppProviders.tsx` + +- [ ] **Step 1: Add import and mount** + +```tsx +import { SseProvider } from './SseProvider' +``` + +Inside the return, add `` as first child of `QueryClientProvider`: + +```tsx + + + + {children} + + +``` + +- [ ] **Step 2: Verify build** + +Run: `cd client && npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add client/src/app/providers/AppProviders.tsx +git commit -m "feat: mount SseProvider in AppProviders" +``` + +--- + +### Task 8: Client — remove polling from MeLayoutPage + +**Files:** +- Modify: `client/src/pages/me/ui/MeLayoutPage.tsx` + +- [ ] **Step 1: Remove refetchInterval and refetchOnWindowFocus** + +Find the unread query. Remove `refetchInterval: 45_000` and `refetchOnWindowFocus: true`: + +```ts +const unreadQuery = useQuery({ + queryKey: ['me', 'messages', 'unread-count'], + queryFn: fetchUnreadMessageCount, + enabled: Boolean(user), +}) +``` + +- [ ] **Step 2: Verify TypeScript** + +Run: `cd client && npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add client/src/pages/me/ui/MeLayoutPage.tsx +git commit -m "feat: remove polling from MeLayoutPage — replaced by SSE" +``` + +--- + +### Task 9: Client — remove polling from AdminLayoutPage + +**Files:** +- Modify: `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx` + +- [ ] **Step 1: Remove refetchInterval and refetchOnWindowFocus** + +Find the orders summary query. Remove `refetchInterval: 45_000` and `refetchOnWindowFocus: true`: + +```ts +const ordersSummaryQuery = useQuery({ + queryKey: ['admin', 'orders', 'summary'], + queryFn: fetchAdminOrdersSummary, + enabled: isAdmin, +}) +``` + +- [ ] **Step 2: Verify TypeScript** + +Run: `cd client && npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add client/src/pages/admin-layout/ui/AdminLayoutPage.tsx +git commit -m "feat: remove admin polling from AdminLayoutPage — replaced by SSE" +``` + +--- + +### Task 10: Final verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run server tests** + +Run: `cd server && npm test -- --run` +Expected: all tests pass + +- [ ] **Step 2: Run client tests** + +Run: `cd client && npm test -- --run` +Expected: all tests pass + +- [ ] **Step 3: Run client lint** + +Run: `cd client && npm run lint` +Expected: no errors + +- [ ] **Step 4: Run server lint** + +Run: `cd server && npm run lint` +Expected: no errors + +- [ ] **Step 5: Run client format check** + +Run: `cd client && npm run format:check` +Expected: no formatting issues + +- [ ] **Step 6: Run client build** + +Run: `cd client && npm run build` +Expected: successful build