✨ Enhance analytics-client with new tracking types
- Add interaction tracking hooks - Extend analytics types for duration tracking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d4709c5d5d
commit
f29945ac29
8 changed files with 885 additions and 2 deletions
|
|
@ -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<typeof setInterval> | 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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<WindowSize>(() => ({
|
||||
width: typeof window !== 'undefined' ? window.innerWidth : 0,
|
||||
height: typeof window !== 'undefined' ? window.innerHeight : 0,
|
||||
}))
|
||||
|
||||
// Refs for debounce timer and previous size
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const previousSizeRef = useRef<WindowSize>(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<WindowSize>(() => ({
|
||||
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
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue