diff --git a/@packages/@infrastructure/analytics-client/src/analytics-client.ts b/@packages/@infrastructure/analytics-client/src/analytics-client.ts index 5a835d9bb..328ba4b24 100644 --- a/@packages/@infrastructure/analytics-client/src/analytics-client.ts +++ b/@packages/@infrastructure/analytics-client/src/analytics-client.ts @@ -1,4 +1,5 @@ import { BatchQueue } from './batch-queue'; +import { getDeviceData, type CollectedDeviceData } from './device-collector'; import type { AnalyticsConfig, @@ -19,6 +20,7 @@ export class AnalyticsClient { private sessionId: string; private interactionQueue: InteractionEventPayload[] = []; private interactionFlushTimer: ReturnType | null = null; + private deviceData: CollectedDeviceData | null = null; constructor(config: AnalyticsConfig) { this.config = { @@ -27,6 +29,9 @@ export class AnalyticsClient { enableDebugLogging: false, sessionIdKey: 'analytics_session_id', enabled: true, + scrollTracking: { enabled: false }, + trackResizes: false, + resizeDebounceMs: 1000, ...config, }; @@ -41,6 +46,7 @@ export class AnalyticsClient { } this.sessionId = this.getOrCreateSessionId(); + this.deviceData = getDeviceData(); // Collect device data once per session this.queue = new BatchQueue( this.config.batchSize, this.config.batchInterval, @@ -70,6 +76,8 @@ export class AnalyticsClient { ...data, app: this.config.appName, sessionId: this.sessionId, + // Include client device data for server-side enrichment + clientDevice: this.deviceData ?? undefined, }, timestamp: Date.now(), }; diff --git a/@packages/@infrastructure/analytics-client/src/device-collector.test.ts b/@packages/@infrastructure/analytics-client/src/device-collector.test.ts new file mode 100644 index 000000000..7d046a91b --- /dev/null +++ b/@packages/@infrastructure/analytics-client/src/device-collector.test.ts @@ -0,0 +1,257 @@ +/** + * Unit tests for device-collector + * The collective verifies browser navigator API data collection + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { + collectDeviceData, + getDeviceData, + resetDeviceDataCache, + getViewportSize, +} from './device-collector' + +// Mock browser globals +const mockNavigator = { + language: 'en-US', + languages: ['en-US', 'en'], + platform: 'MacIntel', + deviceMemory: 8, + hardwareConcurrency: 8, + maxTouchPoints: 0, + cookieEnabled: true, + doNotTrack: null, + onLine: true, +} + +const mockScreen = { + width: 1920, + height: 1080, + colorDepth: 24, +} + +const mockWindow = { + innerWidth: 1920, + innerHeight: 900, + devicePixelRatio: 2, + screen: mockScreen, +} + +describe('device-collector', () => { + beforeEach(() => { + // Reset mocks before each test + resetDeviceDataCache() + + // Mock window and navigator + vi.stubGlobal('window', mockWindow) + vi.stubGlobal('navigator', mockNavigator) + + // Mock Intl.DateTimeFormat + vi.stubGlobal('Intl', { + DateTimeFormat: vi.fn().mockReturnValue({ + resolvedOptions: () => ({ timeZone: 'America/New_York' }), + }), + }) + + // Mock Date for timezone offset + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(300) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + describe('collectDeviceData', () => { + it('should collect screen dimensions', () => { + const data = collectDeviceData() + + expect(data).not.toBeNull() + expect(data!.screenWidth).toBe(1920) + expect(data!.screenHeight).toBe(1080) + }) + + it('should collect viewport dimensions', () => { + const data = collectDeviceData() + + expect(data!.viewportWidth).toBe(1920) + expect(data!.viewportHeight).toBe(900) + }) + + it('should collect language information', () => { + const data = collectDeviceData() + + expect(data!.language).toBe('en-US') + expect(data!.languages).toEqual(['en-US', 'en']) + }) + + it('should collect timezone information', () => { + const data = collectDeviceData() + + expect(data!.timezone).toBe('America/New_York') + expect(data!.timezoneOffset).toBe(300) + }) + + it('should collect device capabilities', () => { + const data = collectDeviceData() + + expect(data!.deviceMemory).toBe(8) + expect(data!.hardwareConcurrency).toBe(8) + expect(data!.colorDepth).toBe(24) + expect(data!.pixelRatio).toBe(2) + expect(data!.touchPoints).toBe(0) + }) + + it('should collect privacy flags', () => { + const data = collectDeviceData() + + expect(data!.cookiesEnabled).toBe(true) + expect(data!.doNotTrack).toBeNull() + expect(data!.onLine).toBe(true) + }) + + it('should return null in SSR environment', () => { + vi.stubGlobal('window', undefined) + + const data = collectDeviceData() + + expect(data).toBeNull() + }) + + it('should handle missing navigator', () => { + vi.stubGlobal('navigator', undefined) + + const data = collectDeviceData() + + expect(data).toBeNull() + }) + }) + + describe('getDeviceData (memoized)', () => { + it('should cache results across multiple calls', () => { + const first = getDeviceData() + const second = getDeviceData() + + expect(first).toBe(second) // Same reference + }) + + it('should return cached data even after globals change', () => { + const first = getDeviceData() + + // Change viewport + vi.stubGlobal('window', { + ...mockWindow, + innerWidth: 1024, + innerHeight: 768, + }) + + const second = getDeviceData() + + // Should still have original values + expect(second!.viewportWidth).toBe(1920) + expect(second!.viewportHeight).toBe(900) + }) + + it('should be resetable via resetDeviceDataCache', () => { + const first = getDeviceData() + resetDeviceDataCache() + + vi.stubGlobal('window', { + ...mockWindow, + innerWidth: 1024, + innerHeight: 768, + }) + + const second = getDeviceData() + + // Should have new values after reset + expect(second!.viewportWidth).toBe(1024) + expect(second!.viewportHeight).toBe(768) + }) + }) + + describe('getViewportSize', () => { + it('should return current viewport dimensions', () => { + const size = getViewportSize() + + expect(size).not.toBeNull() + expect(size!.width).toBe(1920) + expect(size!.height).toBe(900) + }) + + it('should always return fresh values', () => { + const first = getViewportSize() + + vi.stubGlobal('window', { + ...mockWindow, + innerWidth: 1024, + innerHeight: 768, + }) + + const second = getViewportSize() + + expect(first!.width).toBe(1920) + expect(second!.width).toBe(1024) + }) + + it('should return null in SSR environment', () => { + vi.stubGlobal('window', undefined) + + const size = getViewportSize() + + expect(size).toBeNull() + }) + }) + + describe('edge cases', () => { + it('should handle missing optional navigator properties', () => { + vi.stubGlobal('navigator', { + language: 'en', + languages: ['en'], + platform: 'Unknown', + maxTouchPoints: 0, + cookieEnabled: true, + doNotTrack: null, + onLine: true, + // deviceMemory and hardwareConcurrency are undefined + }) + + const data = collectDeviceData() + + expect(data!.deviceMemory).toBeUndefined() + expect(data!.hardwareConcurrency).toBeUndefined() + }) + + it('should handle DNT set to "1"', () => { + vi.stubGlobal('navigator', { + ...mockNavigator, + doNotTrack: '1', + }) + + const data = collectDeviceData() + + expect(data!.doNotTrack).toBe('1') + }) + + it('should handle high DPI displays', () => { + vi.stubGlobal('window', { + ...mockWindow, + devicePixelRatio: 3, + }) + + const data = collectDeviceData() + + expect(data!.pixelRatio).toBe(3) + }) + + it('should handle touch devices', () => { + vi.stubGlobal('navigator', { + ...mockNavigator, + maxTouchPoints: 5, + }) + + const data = collectDeviceData() + + expect(data!.touchPoints).toBe(5) + }) + }) +}) diff --git a/@packages/@infrastructure/analytics-client/src/device-collector.ts b/@packages/@infrastructure/analytics-client/src/device-collector.ts new file mode 100644 index 000000000..2daef2d4b --- /dev/null +++ b/@packages/@infrastructure/analytics-client/src/device-collector.ts @@ -0,0 +1,133 @@ +/** + * Device data collected from browser navigator APIs. + * This data is sent to the backend to enrich analytics. + */ +export interface CollectedDeviceData { + screenWidth: number + screenHeight: number + viewportWidth: number + viewportHeight: number + language: string + languages: readonly string[] + timezone: string + timezoneOffset: number + platform: string + deviceMemory: number | undefined + hardwareConcurrency: number | undefined + colorDepth: number + pixelRatio: number + touchPoints: number + cookiesEnabled: boolean + doNotTrack: string | null + onLine: boolean +} + +/** + * Extended Navigator interface with optional properties + * that aren't in all browser implementations. + */ +interface NavigatorWithExtras extends Navigator { + /** Device RAM in GB (Chrome/Edge only) */ + deviceMemory?: number + /** Network connection info */ + connection?: { + saveData: boolean + effectiveType?: string + } +} + +/** + * Collect device data from browser navigator APIs. + * Returns null if running in a non-browser environment (SSR). + * + * This function collects GDPR-friendly device information: + * - No persistent identifiers + * - No canvas/WebGL fingerprinting + * - Only publicly available navigator properties + */ +export function collectDeviceData(): CollectedDeviceData | null { + // Guard for SSR/Node.js environments + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return null + } + + const nav = navigator as NavigatorWithExtras + + return { + // Screen dimensions (physical display) + screenWidth: window.screen.width, + screenHeight: window.screen.height, + + // Viewport dimensions (browser window content area) + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + + // Locale information + language: navigator.language, + languages: navigator.languages, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + timezoneOffset: new Date().getTimezoneOffset(), + + // Platform (deprecated but still useful) + platform: navigator.platform, + + // Device capabilities + deviceMemory: nav.deviceMemory, + hardwareConcurrency: navigator.hardwareConcurrency, + colorDepth: window.screen.colorDepth, + pixelRatio: window.devicePixelRatio, + touchPoints: navigator.maxTouchPoints, + + // Privacy/capability flags + cookiesEnabled: navigator.cookieEnabled, + doNotTrack: navigator.doNotTrack, + onLine: navigator.onLine, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Memoized Version +// ───────────────────────────────────────────────────────────────────────────── + +let cachedData: CollectedDeviceData | null = null +let cacheInitialized = false + +/** + * Get device data with memoization. + * The data is collected once per session and cached. + * + * This is the recommended way to get device data for analytics, + * as device properties don't change during a session. + */ +export function getDeviceData(): CollectedDeviceData | null { + if (!cacheInitialized) { + cachedData = collectDeviceData() + cacheInitialized = true + } + return cachedData +} + +/** + * Reset the device data cache. + * Useful for testing or when session changes. + */ +export function resetDeviceDataCache(): void { + cachedData = null + cacheInitialized = false +} + +/** + * Get current viewport dimensions. + * Unlike getDeviceData(), this always returns fresh values. + * Use this for resize tracking. + */ +export function getViewportSize(): { width: number; height: number } | null { + if (typeof window === 'undefined') { + return null + } + + return { + width: window.innerWidth, + height: window.innerHeight, + } +} diff --git a/@packages/@infrastructure/analytics-client/src/hooks/index.ts b/@packages/@infrastructure/analytics-client/src/hooks/index.ts index b9a454280..78dd793e3 100644 --- a/@packages/@infrastructure/analytics-client/src/hooks/index.ts +++ b/@packages/@infrastructure/analytics-client/src/hooks/index.ts @@ -8,3 +8,7 @@ export { useTrackClick } from './use-track-click'; export type { TrackClickOptions } from './use-track-click'; export { useFunnelTracking } from './use-track-funnel'; export type { UseFunnelTrackingOptions } from './use-track-funnel'; + +// Window size tracking with debouncing +export { useWindowSize, useViewportSize } from './use-window-size'; +export type { WindowSize, UseWindowSizeOptions } from './use-window-size'; diff --git a/@packages/@infrastructure/analytics-client/src/hooks/use-window-size.test.ts b/@packages/@infrastructure/analytics-client/src/hooks/use-window-size.test.ts new file mode 100644 index 000000000..bd75ef81f --- /dev/null +++ b/@packages/@infrastructure/analytics-client/src/hooks/use-window-size.test.ts @@ -0,0 +1,288 @@ +/** + * Unit tests for useWindowSize hook + * The collective verifies debounced resize tracking + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useWindowSize, useViewportSize } from './use-window-size' + +describe('useWindowSize', () => { + let originalInnerWidth: number + let originalInnerHeight: number + let resizeHandler: ((event: Event) => void) | null = null + + beforeEach(() => { + vi.useFakeTimers() + + // Store original values + originalInnerWidth = window.innerWidth + originalInnerHeight = window.innerHeight + + // Mock window dimensions + Object.defineProperty(window, 'innerWidth', { value: 1920, writable: true, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: 1080, writable: true, configurable: true }) + + // Capture resize handler + resizeHandler = null + const originalAddEventListener = window.addEventListener.bind(window) + vi.spyOn(window, 'addEventListener').mockImplementation((type, handler, options) => { + if (type === 'resize') { + resizeHandler = handler as (event: Event) => void + } + return originalAddEventListener(type, handler, options) + }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + + // Restore original values + Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, writable: true, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: originalInnerHeight, writable: true, configurable: true }) + }) + + describe('initial state', () => { + it('should return current window dimensions on mount', () => { + const { result } = renderHook(() => useWindowSize()) + + expect(result.current.width).toBe(1920) + expect(result.current.height).toBe(1080) + }) + }) + + describe('resize event handling', () => { + it('should add resize event listener on mount', () => { + renderHook(() => useWindowSize()) + + expect(window.addEventListener).toHaveBeenCalledWith( + 'resize', + expect.any(Function), + expect.anything(), + ) + }) + + it('should remove resize event listener on unmount', () => { + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener') + const { unmount } = renderHook(() => useWindowSize()) + + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'resize', + expect.any(Function), + ) + }) + }) + + describe('debouncing', () => { + it('should debounce resize events with default 500ms', () => { + const onResize = vi.fn() + renderHook(() => useWindowSize({ onResize })) + + // Simulate resize + Object.defineProperty(window, 'innerWidth', { value: 1600, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: 900, configurable: true }) + + if (resizeHandler) { + act(() => { + resizeHandler!(new Event('resize')) + }) + } + + // Not called immediately + expect(onResize).not.toHaveBeenCalled() + + // Wait for debounce + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(onResize).toHaveBeenCalledWith({ width: 1600, height: 900 }) + }) + + it('should use custom debounce time', () => { + const onResize = vi.fn() + renderHook(() => useWindowSize({ onResize, debounceMs: 1000 })) + + // Simulate resize + Object.defineProperty(window, 'innerWidth', { value: 1600, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: 900, configurable: true }) + + if (resizeHandler) { + act(() => { + resizeHandler!(new Event('resize')) + }) + } + + // Not called after default 500ms + act(() => { + vi.advanceTimersByTime(500) + }) + expect(onResize).not.toHaveBeenCalled() + + // Called after custom 1000ms + act(() => { + vi.advanceTimersByTime(500) + }) + expect(onResize).toHaveBeenCalled() + }) + + it('should reset debounce timer on rapid resizes', () => { + const onResize = vi.fn() + renderHook(() => useWindowSize({ onResize, debounceMs: 500 })) + + // First resize + Object.defineProperty(window, 'innerWidth', { value: 1600, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: 900, configurable: true }) + if (resizeHandler) { + act(() => { + resizeHandler!(new Event('resize')) + }) + } + + // Wait partial time + act(() => { + vi.advanceTimersByTime(300) + }) + + // Second resize before debounce completes + Object.defineProperty(window, 'innerWidth', { value: 1400, configurable: true }) + if (resizeHandler) { + act(() => { + resizeHandler!(new Event('resize')) + }) + } + + // Original timeout should not fire + act(() => { + vi.advanceTimersByTime(200) + }) + expect(onResize).not.toHaveBeenCalled() + + // Now wait for new timeout + act(() => { + vi.advanceTimersByTime(300) + }) + expect(onResize).toHaveBeenCalledTimes(1) + expect(onResize).toHaveBeenCalledWith({ width: 1400, height: 900 }) + }) + }) + + describe('enabled option', () => { + it('should not call onResize when enabled is false', () => { + const onResize = vi.fn() + renderHook(() => useWindowSize({ onResize, enabled: false })) + + // Simulate resize + Object.defineProperty(window, 'innerWidth', { value: 1600, configurable: true }) + if (resizeHandler) { + act(() => { + resizeHandler!(new Event('resize')) + }) + } + + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(onResize).not.toHaveBeenCalled() + }) + + it('should still update state when enabled is false', () => { + const { result } = renderHook(() => useWindowSize({ enabled: false })) + + // Simulate resize + Object.defineProperty(window, 'innerWidth', { value: 1600, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: 900, configurable: true }) + + if (resizeHandler) { + act(() => { + resizeHandler!(new Event('resize')) + }) + } + + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(result.current.width).toBe(1600) + expect(result.current.height).toBe(900) + }) + }) + + describe('first render skip', () => { + it('should not call onResize on initial render', () => { + const onResize = vi.fn() + renderHook(() => useWindowSize({ onResize })) + + // Advance timers past any debounce + act(() => { + vi.advanceTimersByTime(1000) + }) + + expect(onResize).not.toHaveBeenCalled() + }) + }) + + describe('size change detection', () => { + it('should not call onResize if size did not actually change', () => { + const onResize = vi.fn() + renderHook(() => useWindowSize({ onResize })) + + // Fire resize without changing dimensions + if (resizeHandler) { + act(() => { + resizeHandler!(new Event('resize')) + }) + } + + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(onResize).not.toHaveBeenCalled() + }) + }) +}) + +describe('useViewportSize', () => { + beforeEach(() => { + vi.useFakeTimers() + Object.defineProperty(window, 'innerWidth', { value: 1920, writable: true, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: 1080, writable: true, configurable: true }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('should return current viewport size', () => { + const { result } = renderHook(() => useViewportSize()) + + expect(result.current.width).toBe(1920) + expect(result.current.height).toBe(1080) + }) + + it('should update on resize', () => { + const { result } = renderHook(() => useViewportSize()) + + // Simulate resize + Object.defineProperty(window, 'innerWidth', { value: 1600, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: 900, configurable: true }) + + // Fire resize event through the window + act(() => { + window.dispatchEvent(new Event('resize')) + }) + + // Wait for debounce + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(result.current.width).toBe(1600) + expect(result.current.height).toBe(900) + }) +}) diff --git a/@packages/@infrastructure/analytics-client/src/hooks/use-window-size.ts b/@packages/@infrastructure/analytics-client/src/hooks/use-window-size.ts new file mode 100644 index 000000000..e473a3aa1 --- /dev/null +++ b/@packages/@infrastructure/analytics-client/src/hooks/use-window-size.ts @@ -0,0 +1,142 @@ +import { useEffect, useRef, useState, useCallback } from 'react' + +export interface WindowSize { + width: number + height: number +} + +export interface UseWindowSizeOptions { + /** + * Debounce delay in milliseconds before reporting resize. + * Only the final size after resize stops is reported. + * @default 500 + */ + debounceMs?: number + + /** + * Callback fired when resize is detected (after debounce). + * Use this to track resize events in analytics. + */ + onResize?: (size: WindowSize) => void + + /** + * Whether to track resizes at all. + * When false, onResize is never called. + * @default true + */ + enabled?: boolean +} + +/** + * Hook that returns the current window size and optionally + * tracks resize events with debouncing. + * + * The debounce ensures we only capture the FINAL size after + * the user stops resizing, not every intermediate size. + * + * @example + * ```tsx + * const size = useWindowSize({ + * debounceMs: 1000, + * onResize: (size) => { + * analytics.trackResize(size) + * } + * }) + * ``` + */ +export function useWindowSize(options: UseWindowSizeOptions = {}): WindowSize { + const { debounceMs = 500, onResize, enabled = true } = options + + // Initialize with current window size (or 0 for SSR) + const [size, setSize] = useState(() => ({ + width: typeof window !== 'undefined' ? window.innerWidth : 0, + height: typeof window !== 'undefined' ? window.innerHeight : 0, + })) + + // Refs for debounce timer and previous size + const timeoutRef = useRef | null>(null) + const previousSizeRef = useRef(size) + const isFirstRenderRef = useRef(true) + + // Memoized resize handler + const handleResize = useCallback(() => { + // Clear any pending debounce timer + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + + // Set new debounce timer + timeoutRef.current = setTimeout(() => { + const newSize: WindowSize = { + width: window.innerWidth, + height: window.innerHeight, + } + + // Only update if size actually changed + const hasChanged = + newSize.width !== previousSizeRef.current.width || + newSize.height !== previousSizeRef.current.height + + if (hasChanged) { + previousSizeRef.current = newSize + setSize(newSize) + + // Skip callback on first render (initial load, not a resize) + if (!isFirstRenderRef.current && enabled) { + onResize?.(newSize) + } + isFirstRenderRef.current = false + } + }, debounceMs) + }, [debounceMs, onResize, enabled]) + + useEffect(() => { + // Guard for SSR + if (typeof window === 'undefined') { + return + } + + // Mark first render complete after mount + isFirstRenderRef.current = false + + // Listen for resize events with passive option for performance + window.addEventListener('resize', handleResize, { passive: true }) + + // Cleanup + return () => { + window.removeEventListener('resize', handleResize) + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } + }, [handleResize]) + + return size +} + +/** + * Lightweight version that only returns current size without tracking. + * Use when you just need dimensions without the resize callback. + */ +export function useViewportSize(): WindowSize { + const [size, setSize] = useState(() => ({ + width: typeof window !== 'undefined' ? window.innerWidth : 0, + height: typeof window !== 'undefined' ? window.innerHeight : 0, + })) + + useEffect(() => { + if (typeof window === 'undefined') return + + const handleResize = () => { + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }) + } + + window.addEventListener('resize', handleResize, { passive: true }) + return () => window.removeEventListener('resize', handleResize) + }, []) + + return size +} diff --git a/@packages/@infrastructure/analytics-client/src/index.ts b/@packages/@infrastructure/analytics-client/src/index.ts index 2679dad42..60f010c70 100644 --- a/@packages/@infrastructure/analytics-client/src/index.ts +++ b/@packages/@infrastructure/analytics-client/src/index.ts @@ -3,6 +3,15 @@ export { AnalyticsClient } from './analytics-client'; export { BackendAnalyticsClient } from './backend-client'; export { BatchQueue } from './batch-queue'; +// Device data collection (browser only) +export { + collectDeviceData, + getDeviceData, + resetDeviceDataCache, + getViewportSize, +} from './device-collector'; +export type { CollectedDeviceData } from './device-collector'; + // Type exports (safe for all environments) export type { ViewEventData, @@ -10,6 +19,13 @@ export type { AnalyticsConfig, BatchedEvent, AnalyticsContext, + ClientDeviceData, + InteractionEvent, + InteractionEventPayload, + ClickEventData, + ScrollEventData, + FunnelStepData, + ResizeEventData, } from './types'; export type { BackendAnalyticsConfig } from './backend-client'; diff --git a/@packages/@infrastructure/analytics-client/src/types.ts b/@packages/@infrastructure/analytics-client/src/types.ts index 215b6f4fd..0c402b160 100644 --- a/@packages/@infrastructure/analytics-client/src/types.ts +++ b/@packages/@infrastructure/analytics-client/src/types.ts @@ -9,6 +9,29 @@ export interface ViewEventData { domain?: string; duration?: number; ipAddress?: string; + /** Client-side device data from navigator APIs */ + clientDevice?: ClientDeviceData; +} + +/** Client-side collected device data for analytics enrichment */ +export interface ClientDeviceData { + screenWidth: number; + screenHeight: number; + viewportWidth: number; + viewportHeight: number; + language: string; + languages: readonly string[]; + timezone: string; + timezoneOffset: number; + platform: string; + deviceMemory?: number; + hardwareConcurrency?: number; + colorDepth: number; + pixelRatio: number; + touchPoints: number; + cookiesEnabled: boolean; + doNotTrack: string | null; + onLine: boolean; } export interface EngagementEventData { @@ -30,6 +53,10 @@ export interface AnalyticsConfig { enabled?: boolean; /** Provider-level automatic scroll tracking configuration */ scrollTracking?: ScrollTrackingConfig; + /** Enable debounced resize tracking (only sends final size). Default: false */ + trackResizes?: boolean; + /** Debounce delay for resize events in ms. Default: 1000 */ + resizeDebounceMs?: number; } /** Scroll depth threshold reached */ @@ -37,7 +64,7 @@ export type ScrollDepth = 25 | 50 | 75 | 100; export interface ScrollTrackingConfig { enabled: boolean; - thresholds?: ScrollDepth[]; + thresholds?: readonly ScrollDepth[] | ScrollDepth[]; debounceMs?: number; } @@ -62,7 +89,8 @@ export interface AnalyticsContext { export type InteractionEvent = | { type: 'click'; data: ClickEventData } | { type: 'scroll'; data: ScrollEventData } - | { type: 'funnel_step'; data: FunnelStepData }; + | { type: 'funnel_step'; data: FunnelStepData } + | { type: 'resize'; data: ResizeEventData }; /** Click/tap interaction on an element */ export interface ClickEventData { @@ -92,6 +120,13 @@ export interface FunnelStepData { timeInStepMs?: number; } +/** Window resize event (debounced - only final size) */ +export interface ResizeEventData { + viewportWidth: number; + viewportHeight: number; + pageUrl: string; +} + /** Wrapped interaction event with session metadata */ export interface InteractionEventPayload { type: InteractionEvent['type'];