diff --git a/ui/src/components/NotificationBell.module.css b/ui/src/components/NotificationBell.module.css new file mode 100644 index 00000000..cf6662a9 --- /dev/null +++ b/ui/src/components/NotificationBell.module.css @@ -0,0 +1,27 @@ +.bell { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + color: var(--fg); + text-decoration: none; +} +.bell:hover { background: var(--hover-bg); } +.badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background: var(--error); + color: var(--bg); + font-size: 10px; + font-weight: 600; + line-height: 16px; + text-align: center; +} diff --git a/ui/src/components/NotificationBell.test.tsx b/ui/src/components/NotificationBell.test.tsx new file mode 100644 index 00000000..0cd05ae3 --- /dev/null +++ b/ui/src/components/NotificationBell.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router'; +import type { ReactNode } from 'react'; +import { useEnvironmentStore } from '../api/environment-store'; + +vi.mock('../api/client', () => ({ api: { GET: vi.fn() } })); + +import { api as apiClient } from '../api/client'; +import { NotificationBell } from './NotificationBell'; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + {children} + + ); +} + +describe('NotificationBell', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'dev' }); + }); + + it('renders bell with no badge when zero unread', async () => { + (apiClient.GET as any).mockResolvedValue({ + data: { count: 0 }, + error: null, + }); + render(, { wrapper }); + expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument(); + // Badge is only rendered when count > 0; no numeric text should appear. + expect(screen.queryByText(/^\d+$/)).toBeNull(); + }); + + it('shows unread count badge when unread alerts exist', async () => { + (apiClient.GET as any).mockResolvedValue({ + data: { count: 3 }, + error: null, + }); + render(, { wrapper }); + expect(await screen.findByText('3')).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/NotificationBell.tsx b/ui/src/components/NotificationBell.tsx new file mode 100644 index 00000000..d2b7738d --- /dev/null +++ b/ui/src/components/NotificationBell.tsx @@ -0,0 +1,51 @@ +import { Link } from 'react-router'; +import { Bell } from 'lucide-react'; +import { useUnreadCount } from '../api/queries/alerts'; +import { useSelectedEnv } from '../api/queries/alertMeta'; +import { usePageVisible } from '../hooks/usePageVisible'; +import css from './NotificationBell.module.css'; + +/** + * Global notification bell shown in the layout header. Links to the alerts + * inbox and renders a badge with the unread-alert count for the currently + * selected environment. + * + * Polling is driven by `useUnreadCount` (30s interval, paused in background + * via TanStack Query's `refetchIntervalInBackground: false`). The + * `usePageVisible` hook is retained as a defense-in-depth signal so future + * UI behavior (e.g. animations, live-region updates) can key off visibility + * without re-wiring the polling logic. + * + * TODO (spec §13): per-severity badge coloring — the backend + * `UnreadCountResponse` currently exposes only a scalar `count` field. To + * colour the badge by max unread severity (CRITICAL → error, WARNING → + * amber, INFO → muted) the DTO must grow a `bySeverity` map; deferred to a + * future task. Until then the badge uses a single `var(--error)` tint. + */ +export function NotificationBell() { + const env = useSelectedEnv(); + // Subscribe to visibility so the component re-renders on tab focus, even + // though the polling pause itself is handled inside useUnreadCount. + usePageVisible(); + const { data } = useUnreadCount(); + + const count = data?.count ?? 0; + + if (!env) return null; + + return ( + + + {count > 0 && ( + + {count > 99 ? '99+' : count} + + )} + + ); +} diff --git a/ui/src/hooks/usePageVisible.test.ts b/ui/src/hooks/usePageVisible.test.ts new file mode 100644 index 00000000..488fcc28 --- /dev/null +++ b/ui/src/hooks/usePageVisible.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { usePageVisible } from './usePageVisible'; + +describe('usePageVisible', () => { + beforeEach(() => { + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + configurable: true, + writable: true, + }); + }); + + it('returns true when visible, false when hidden', () => { + const { result } = renderHook(() => usePageVisible()); + expect(result.current).toBe(true); + + act(() => { + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(result.current).toBe(false); + + act(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(result.current).toBe(true); + }); +}); diff --git a/ui/src/hooks/usePageVisible.ts b/ui/src/hooks/usePageVisible.ts new file mode 100644 index 00000000..fbfc4654 --- /dev/null +++ b/ui/src/hooks/usePageVisible.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; + +/** + * Tracks Page Visibility API state for the current document. + * + * Returns `true` when the tab is visible, `false` when hidden. Useful for + * pausing work (polling, animations, expensive DOM effects) while the tab + * is backgrounded. SSR-safe: defaults to `true` when `document` is undefined. + */ +export function usePageVisible(): boolean { + const [visible, setVisible] = useState(() => + typeof document === 'undefined' ? true : document.visibilityState === 'visible', + ); + + useEffect(() => { + const onChange = () => setVisible(document.visibilityState === 'visible'); + document.addEventListener('visibilitychange', onChange); + return () => document.removeEventListener('visibilitychange', onChange); + }, []); + + return visible; +}