From 1e9ebd14ffdd46ceb369b0c8386e3e704fbfa9b9 Mon Sep 17 00:00:00 2001 From: Sergei Date: Sat, 31 Jan 2026 16:20:48 -0800 Subject: [PATCH] Add sensor setup analytics tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented comprehensive analytics system for tracking sensor setup process including scan events, setup steps, and completion metrics. Features: - Analytics service with event tracking for sensor setup - Metrics calculation (success rate, duration, common errors) - Integration in add-sensor and setup-wifi screens - Tracks: scan start/complete, setup start/complete, individual steps, retries, skips, and cancellations - Comprehensive test coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/(tabs)/beneficiaries/[id]/add-sensor.tsx | 29 +- app/(tabs)/beneficiaries/[id]/setup-wifi.tsx | 87 ++++- services/__tests__/analytics.test.ts | 358 +++++++++++++++++++ services/analytics.ts | 260 ++++++++++++++ 4 files changed, 731 insertions(+), 3 deletions(-) create mode 100644 services/__tests__/analytics.test.ts create mode 100644 services/analytics.ts diff --git a/app/(tabs)/beneficiaries/[id]/add-sensor.tsx b/app/(tabs)/beneficiaries/[id]/add-sensor.tsx index ecce087..81a208e 100644 --- a/app/(tabs)/beneficiaries/[id]/add-sensor.tsx +++ b/app/(tabs)/beneficiaries/[id]/add-sensor.tsx @@ -13,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router, useLocalSearchParams, useFocusEffect } from 'expo-router'; import { useBLE } from '@/contexts/BLEContext'; +import { analytics } from '@/services/analytics'; import { AppColors, BorderRadius, @@ -37,13 +38,24 @@ export default function AddSensorScreen() { } = useBLE(); const [selectedDevices, setSelectedDevices] = useState>(new Set()); + const [scanStartTime, setScanStartTime] = useState(null); // Select all devices by default when scan completes useEffect(() => { if (foundDevices.length > 0 && !isScanning) { setSelectedDevices(new Set(foundDevices.map(d => d.id))); + + // Track scan completion + if (scanStartTime && id) { + analytics.trackSensorScanComplete({ + beneficiaryId: id, + scanDuration: Date.now() - scanStartTime, + sensorsFound: foundDevices.length, + selectedCount: foundDevices.length, + }); + } } - }, [foundDevices, isScanning]); + }, [foundDevices, isScanning, scanStartTime, id]); // Cleanup: Stop scan when screen loses focus or component unmounts useFocusEffect( @@ -85,6 +97,12 @@ export default function AddSensorScreen() { // Clear any previous errors clearError(); + // Track scan start + if (id) { + analytics.trackSensorScanStart(id); + setScanStartTime(Date.now()); + } + // Perform scan await scanDevices(); } catch (error: any) { @@ -109,6 +127,15 @@ export default function AddSensorScreen() { const devices = foundDevices.filter(d => selectedDevices.has(d.id)); + // Track setup initiation + if (id && scanStartTime) { + analytics.trackSensorSetupStart({ + beneficiaryId: id, + sensorCount: selectedCount, + scanDuration: Date.now() - scanStartTime, + }); + } + // Navigate to Setup WiFi screen with selected devices router.push({ pathname: `/(tabs)/beneficiaries/${id}/setup-wifi` as any, diff --git a/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx b/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx index dc80949..34d49e8 100644 --- a/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx +++ b/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx @@ -17,6 +17,7 @@ import { router, useLocalSearchParams } from 'expo-router'; import * as Device from 'expo-device'; import { useBLE } from '@/contexts/BLEContext'; import { api } from '@/services/api'; +import { analytics } from '@/services/analytics'; import * as wifiPasswordStore from '@/services/wifiPasswordStore'; import type { WiFiNetwork } from '@/services/ble'; import type { @@ -112,6 +113,7 @@ export default function SetupWiFiScreen() { const [isPaused, setIsPaused] = useState(false); const setupInProgressRef = useRef(false); const shouldCancelRef = useRef(false); + const [setupStartTime, setSetupStartTime] = useState(null); // Saved WiFi passwords map (SSID -> password) // Using useState to trigger re-renders when passwords are loaded @@ -202,7 +204,27 @@ export default function SetupWiFiScreen() { ), }; })); - }, []); + + // Track step progress + if (id) { + const sensorIndex = sensors.findIndex(s => s.deviceId === deviceId); + const stepStatusMap: Record = { + pending: 'started', + in_progress: 'started', + completed: 'completed', + failed: 'failed', + }; + + analytics.trackSensorSetupStep({ + beneficiaryId: id, + sensorIndex, + totalSensors: sensors.length, + step: stepName, + stepStatus: stepStatusMap[stepStatus], + error, + }); + } + }, [id, sensors]); // Update sensor status const updateSensorStatus = useCallback(( @@ -394,9 +416,33 @@ export default function SetupWiFiScreen() { ); if (allProcessed || shouldCancelRef.current) { + // Track setup completion + if (id && setupStartTime) { + const successCount = finalSensors.filter(s => s.status === 'success').length; + const failureCount = finalSensors.filter(s => s.status === 'error').length; + const skippedCount = finalSensors.filter(s => s.status === 'skipped').length; + const totalDuration = Date.now() - setupStartTime; + + // Calculate average sensor setup time (only successful ones) + const successfulSensors = finalSensors.filter(s => s.status === 'success' && s.startTime && s.endTime); + const averageSensorSetupTime = successfulSensors.length > 0 + ? successfulSensors.reduce((sum, s) => sum + (s.endTime! - s.startTime!), 0) / successfulSensors.length + : undefined; + + analytics.trackSensorSetupComplete({ + beneficiaryId: id, + totalSensors: finalSensors.length, + successCount, + failureCount, + skippedCount, + totalDuration, + averageSensorSetupTime, + }); + } + setPhase('results'); } - }, [sensors, currentIndex, selectedNetwork, password, isPaused, processSensor]); + }, [sensors, currentIndex, selectedNetwork, password, isPaused, processSensor, id, setupStartTime]); // Start batch setup const handleStartBatchSetup = async () => { @@ -422,6 +468,9 @@ export default function SetupWiFiScreen() { // Continue with setup even if save fails } + // Track setup start time + setSetupStartTime(Date.now()); + // Initialize sensor states const initialStates = selectedDevices.map(createSensorState); setSensors(initialStates); @@ -441,6 +490,18 @@ export default function SetupWiFiScreen() { const handleRetry = (deviceId: string) => { const index = sensors.findIndex(s => s.deviceId === deviceId); if (index >= 0) { + const sensor = sensors[index]; + + // Track retry + if (id && sensor.error) { + analytics.trackSensorSetupRetry({ + beneficiaryId: id, + sensorId: deviceId, + sensorName: sensor.deviceName, + previousError: sensor.error, + }); + } + setSensors(prev => prev.map(s => s.deviceId === deviceId ? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() } @@ -454,6 +515,18 @@ export default function SetupWiFiScreen() { // Skip failed sensor const handleSkip = (deviceId: string) => { + const sensor = sensors.find(s => s.deviceId === deviceId); + + // Track skip + if (id && sensor?.error) { + analytics.trackSensorSetupSkip({ + beneficiaryId: id, + sensorId: deviceId, + sensorName: sensor.deviceName, + error: sensor.error, + }); + } + setSensors(prev => prev.map(s => s.deviceId === deviceId ? { ...s, status: 'skipped' as SensorSetupStatus } @@ -482,6 +555,16 @@ export default function SetupWiFiScreen() { text: 'Cancel', style: 'destructive', onPress: () => { + // Track cancellation + if (id) { + analytics.trackSensorSetupCancelled({ + beneficiaryId: id, + currentSensorIndex: currentIndex, + totalSensors: sensors.length, + reason: 'user_cancelled', + }); + } + shouldCancelRef.current = true; setupInProgressRef.current = false; // Disconnect all devices diff --git a/services/__tests__/analytics.test.ts b/services/__tests__/analytics.test.ts new file mode 100644 index 0000000..ec53e04 --- /dev/null +++ b/services/__tests__/analytics.test.ts @@ -0,0 +1,358 @@ +/** + * Analytics Service Tests + */ + +import { analytics } from '../analytics'; + +describe('Analytics Service', () => { + beforeEach(() => { + // Clear events before each test + analytics.clearEvents(); + analytics.setEnabled(true); + }); + + describe('Basic Event Tracking', () => { + it('should track a generic event', () => { + analytics.track('test_event', { foo: 'bar' }); + + const events = analytics.getEvents(); + expect(events).toHaveLength(1); + expect(events[0].name).toBe('test_event'); + expect(events[0].properties).toEqual({ foo: 'bar' }); + expect(events[0].timestamp).toBeGreaterThan(0); + }); + + it('should track events without properties', () => { + analytics.track('simple_event'); + + const events = analytics.getEvents(); + expect(events).toHaveLength(1); + expect(events[0].name).toBe('simple_event'); + expect(events[0].properties).toBeUndefined(); + }); + + it('should not track events when disabled', () => { + analytics.setEnabled(false); + analytics.track('disabled_event'); + + const events = analytics.getEvents(); + expect(events).toHaveLength(0); + }); + }); + + describe('Sensor Scan Events', () => { + it('should track sensor scan start', () => { + analytics.trackSensorScanStart('beneficiary_123'); + + const events = analytics.getEvents(); + expect(events).toHaveLength(1); + expect(events[0].name).toBe('sensor_scan_start'); + expect(events[0].properties).toEqual({ beneficiaryId: 'beneficiary_123' }); + }); + + it('should track sensor scan completion', () => { + analytics.trackSensorScanComplete({ + beneficiaryId: 'beneficiary_123', + scanDuration: 5000, + sensorsFound: 3, + selectedCount: 2, + }); + + const events = analytics.getEvents(); + expect(events).toHaveLength(1); + expect(events[0].name).toBe('sensor_scan_complete'); + expect(events[0].properties).toMatchObject({ + beneficiaryId: 'beneficiary_123', + scanDuration: 5000, + sensorsFound: 3, + selectedCount: 2, + }); + }); + }); + + describe('Sensor Setup Events', () => { + it('should track setup start', () => { + analytics.trackSensorSetupStart({ + beneficiaryId: 'beneficiary_123', + sensorCount: 2, + scanDuration: 5000, + }); + + const events = analytics.getEvents(); + expect(events).toHaveLength(1); + expect(events[0].name).toBe('sensor_setup_start'); + expect(events[0].properties).toMatchObject({ + beneficiaryId: 'beneficiary_123', + sensorCount: 2, + scanDuration: 5000, + }); + }); + + it('should track setup steps', () => { + analytics.trackSensorSetupStep({ + beneficiaryId: 'beneficiary_123', + sensorIndex: 0, + totalSensors: 2, + step: 'connect', + stepStatus: 'started', + }); + + analytics.trackSensorSetupStep({ + beneficiaryId: 'beneficiary_123', + sensorIndex: 0, + totalSensors: 2, + step: 'connect', + stepStatus: 'completed', + }); + + const events = analytics.getEvents(); + expect(events).toHaveLength(2); + expect(events[0].properties?.step).toBe('connect'); + expect(events[0].properties?.stepStatus).toBe('started'); + expect(events[1].properties?.stepStatus).toBe('completed'); + }); + + it('should track setup step failures with error', () => { + analytics.trackSensorSetupStep({ + beneficiaryId: 'beneficiary_123', + sensorIndex: 0, + totalSensors: 2, + step: 'wifi', + stepStatus: 'failed', + error: 'Connection timeout', + }); + + const events = analytics.getEvents(); + expect(events[0].properties?.error).toBe('Connection timeout'); + }); + + it('should track setup completion', () => { + analytics.trackSensorSetupComplete({ + beneficiaryId: 'beneficiary_123', + totalSensors: 3, + successCount: 2, + failureCount: 1, + skippedCount: 0, + totalDuration: 45000, + averageSensorSetupTime: 15000, + }); + + const events = analytics.getEvents(); + expect(events).toHaveLength(1); + expect(events[0].name).toBe('sensor_setup_complete'); + expect(events[0].properties).toMatchObject({ + totalSensors: 3, + successCount: 2, + failureCount: 1, + skippedCount: 0, + totalDuration: 45000, + averageSensorSetupTime: 15000, + }); + }); + + it('should track retry attempts', () => { + analytics.trackSensorSetupRetry({ + beneficiaryId: 'beneficiary_123', + sensorId: 'sensor_1', + sensorName: 'WP_123', + previousError: 'Connection failed', + }); + + const events = analytics.getEvents(); + expect(events).toHaveLength(1); + expect(events[0].name).toBe('sensor_setup_retry'); + expect(events[0].properties).toMatchObject({ + sensorId: 'sensor_1', + previousError: 'Connection failed', + }); + }); + + it('should track skip actions', () => { + analytics.trackSensorSetupSkip({ + beneficiaryId: 'beneficiary_123', + sensorId: 'sensor_1', + sensorName: 'WP_123', + error: 'Connection failed', + }); + + const events = analytics.getEvents(); + expect(events).toHaveLength(1); + expect(events[0].name).toBe('sensor_setup_skip'); + }); + + it('should track cancellation', () => { + analytics.trackSensorSetupCancelled({ + beneficiaryId: 'beneficiary_123', + currentSensorIndex: 1, + totalSensors: 3, + reason: 'user_cancelled', + }); + + const events = analytics.getEvents(); + expect(events).toHaveLength(1); + expect(events[0].name).toBe('sensor_setup_cancelled'); + expect(events[0].properties?.reason).toBe('user_cancelled'); + }); + }); + + describe('Event Filtering', () => { + beforeEach(() => { + analytics.track('event_a', { value: 1 }); + analytics.track('event_b', { value: 2 }); + analytics.track('event_a', { value: 3 }); + }); + + it('should filter events by name', () => { + const eventAEvents = analytics.getEventsByName('event_a'); + expect(eventAEvents).toHaveLength(2); + expect(eventAEvents[0].properties?.value).toBe(1); + expect(eventAEvents[1].properties?.value).toBe(3); + }); + + it('should return empty array for non-existent event name', () => { + const events = analytics.getEventsByName('non_existent'); + expect(events).toHaveLength(0); + }); + + it('should filter events by time range', () => { + const now = Date.now(); + const events = analytics.getEventsInRange(now - 1000, now + 1000); + expect(events).toHaveLength(3); + }); + + it('should return empty array for out-of-range queries', () => { + const now = Date.now(); + const events = analytics.getEventsInRange(now - 10000, now - 5000); + expect(events).toHaveLength(0); + }); + }); + + describe('Event Storage Management', () => { + it('should clear all events', () => { + analytics.track('event_1'); + analytics.track('event_2'); + expect(analytics.getEvents()).toHaveLength(2); + + analytics.clearEvents(); + expect(analytics.getEvents()).toHaveLength(0); + }); + + it('should trim events when exceeding max storage', () => { + // Create 1000 events (the max) + for (let i = 0; i < 1000; i++) { + analytics.track(`event_${i}`); + } + expect(analytics.getEvents()).toHaveLength(1000); + + // Add one more - should trim oldest + analytics.track('event_1001'); + const events = analytics.getEvents(); + expect(events).toHaveLength(1000); + expect(events[0].name).toBe('event_1'); // First event was removed + expect(events[events.length - 1].name).toBe('event_1001'); // Last event is newest + }); + }); + + describe('Sensor Setup Metrics', () => { + beforeEach(() => { + // Simulate successful setup + analytics.trackSensorSetupComplete({ + beneficiaryId: 'beneficiary_1', + totalSensors: 3, + successCount: 3, + failureCount: 0, + skippedCount: 0, + totalDuration: 30000, + averageSensorSetupTime: 10000, + }); + + // Simulate partial failure + analytics.trackSensorSetupComplete({ + beneficiaryId: 'beneficiary_2', + totalSensors: 2, + successCount: 1, + failureCount: 1, + skippedCount: 0, + totalDuration: 25000, + }); + + // Add some step failures + analytics.trackSensorSetupStep({ + beneficiaryId: 'beneficiary_2', + sensorIndex: 1, + totalSensors: 2, + step: 'wifi', + stepStatus: 'failed', + error: 'Connection timeout', + }); + + analytics.trackSensorSetupStep({ + beneficiaryId: 'beneficiary_2', + sensorIndex: 1, + totalSensors: 2, + step: 'wifi', + stepStatus: 'failed', + error: 'Connection timeout', + }); + + analytics.trackSensorSetupStep({ + beneficiaryId: 'beneficiary_1', + sensorIndex: 0, + totalSensors: 3, + step: 'attach', + stepStatus: 'failed', + error: 'API error', + }); + }); + + it('should calculate total setups', () => { + const metrics = analytics.getSensorSetupMetrics(); + expect(metrics.totalSetups).toBe(2); + }); + + it('should calculate success rate', () => { + const metrics = analytics.getSensorSetupMetrics(); + // 4 successful out of 5 total sensors = 80% + expect(metrics.successRate).toBe(80); + }); + + it('should calculate average duration', () => { + const metrics = analytics.getSensorSetupMetrics(); + // (30000 + 25000) / 2 = 27500 + expect(metrics.averageDuration).toBe(27500); + }); + + it('should calculate average sensors per setup', () => { + const metrics = analytics.getSensorSetupMetrics(); + // (3 + 2) / 2 = 2.5 + expect(metrics.averageSensorsPerSetup).toBe(2.5); + }); + + it('should identify common errors', () => { + const metrics = analytics.getSensorSetupMetrics(); + expect(metrics.commonErrors).toHaveLength(2); + expect(metrics.commonErrors[0].error).toBe('Connection timeout'); + expect(metrics.commonErrors[0].count).toBe(2); + expect(metrics.commonErrors[1].error).toBe('API error'); + expect(metrics.commonErrors[1].count).toBe(1); + }); + + it('should handle empty metrics gracefully', () => { + analytics.clearEvents(); + const metrics = analytics.getSensorSetupMetrics(); + expect(metrics).toEqual({ + totalSetups: 0, + successRate: 0, + averageDuration: 0, + averageSensorsPerSetup: 0, + commonErrors: [], + }); + }); + + it('should filter metrics by time range', () => { + const now = Date.now(); + const metrics = analytics.getSensorSetupMetrics(1000); // Last second + expect(metrics.totalSetups).toBe(2); // All events are recent + }); + }); +}); diff --git a/services/analytics.ts b/services/analytics.ts new file mode 100644 index 0000000..e8d7760 --- /dev/null +++ b/services/analytics.ts @@ -0,0 +1,260 @@ +/** + * Analytics Service + * + * Simple, lightweight analytics tracking for sensor setup and other app events. + * Logs events locally for now - can be extended to send to analytics platforms + * (Mixpanel, Amplitude, etc.) in the future. + */ + +export interface AnalyticsEvent { + name: string; + timestamp: number; + properties?: Record; +} + +export interface SensorSetupStartProperties { + beneficiaryId: string; + sensorCount: number; + scanDuration?: number; // milliseconds +} + +export interface SensorSetupProgressProperties { + beneficiaryId: string; + sensorIndex: number; + totalSensors: number; + step: 'connect' | 'unlock' | 'wifi' | 'attach' | 'reboot'; + stepStatus: 'started' | 'completed' | 'failed'; + error?: string; +} + +export interface SensorSetupCompleteProperties { + beneficiaryId: string; + totalSensors: number; + successCount: number; + failureCount: number; + skippedCount: number; + totalDuration: number; // milliseconds + averageSensorSetupTime?: number; // milliseconds +} + +export interface SensorScanProperties { + beneficiaryId: string; + scanDuration: number; // milliseconds + sensorsFound: number; + selectedCount: number; +} + +class AnalyticsService { + private events: AnalyticsEvent[] = []; + private readonly maxStoredEvents = 1000; + private isEnabled = true; + + /** + * Track a generic event + */ + track(eventName: string, properties?: Record): void { + if (!this.isEnabled) return; + + const event: AnalyticsEvent = { + name: eventName, + timestamp: Date.now(), + properties, + }; + + this.events.push(event); + this.trimEvents(); + + // Log to console in development + if (__DEV__) { + console.log('[Analytics]', eventName, properties); + } + } + + // === Sensor Setup Events === + + /** + * Track when user starts scanning for sensors + */ + trackSensorScanStart(beneficiaryId: string): void { + this.track('sensor_scan_start', { beneficiaryId }); + } + + /** + * Track scan results + */ + trackSensorScanComplete(properties: SensorScanProperties): void { + this.track('sensor_scan_complete', properties); + } + + /** + * Track when batch setup begins + */ + trackSensorSetupStart(properties: SensorSetupStartProperties): void { + this.track('sensor_setup_start', properties); + } + + /** + * Track individual step progress + */ + trackSensorSetupStep(properties: SensorSetupProgressProperties): void { + this.track('sensor_setup_step', properties); + } + + /** + * Track setup completion + */ + trackSensorSetupComplete(properties: SensorSetupCompleteProperties): void { + this.track('sensor_setup_complete', properties); + } + + /** + * Track when user cancels setup + */ + trackSensorSetupCancelled(properties: { + beneficiaryId: string; + currentSensorIndex: number; + totalSensors: number; + reason?: string; + }): void { + this.track('sensor_setup_cancelled', properties); + } + + /** + * Track retry attempt + */ + trackSensorSetupRetry(properties: { + beneficiaryId: string; + sensorId: string; + sensorName: string; + previousError: string; + }): void { + this.track('sensor_setup_retry', properties); + } + + /** + * Track skip action + */ + trackSensorSetupSkip(properties: { + beneficiaryId: string; + sensorId: string; + sensorName: string; + error: string; + }): void { + this.track('sensor_setup_skip', properties); + } + + // === Utility Methods === + + /** + * Get all stored events + */ + getEvents(): AnalyticsEvent[] { + return [...this.events]; + } + + /** + * Get events filtered by name + */ + getEventsByName(eventName: string): AnalyticsEvent[] { + return this.events.filter(e => e.name === eventName); + } + + /** + * Get events within time range + */ + getEventsInRange(startTime: number, endTime: number): AnalyticsEvent[] { + return this.events.filter( + e => e.timestamp >= startTime && e.timestamp <= endTime + ); + } + + /** + * Clear all stored events + */ + clearEvents(): void { + this.events = []; + } + + /** + * Enable/disable tracking + */ + setEnabled(enabled: boolean): void { + this.isEnabled = enabled; + } + + /** + * Trim events array to max size (FIFO) + */ + private trimEvents(): void { + if (this.events.length > this.maxStoredEvents) { + this.events = this.events.slice(-this.maxStoredEvents); + } + } + + /** + * Get sensor setup metrics summary + */ + getSensorSetupMetrics(timeRangeMs?: number): { + totalSetups: number; + successRate: number; + averageDuration: number; + averageSensorsPerSetup: number; + commonErrors: { error: string; count: number }[]; + } { + const now = Date.now(); + const startTime = timeRangeMs ? now - timeRangeMs : 0; + + const setupEvents = this.getEventsInRange(startTime, now).filter( + e => e.name === 'sensor_setup_complete' + ); + + if (setupEvents.length === 0) { + return { + totalSetups: 0, + successRate: 0, + averageDuration: 0, + averageSensorsPerSetup: 0, + commonErrors: [], + }; + } + + const totalSetups = setupEvents.length; + let totalSuccess = 0; + let totalDuration = 0; + let totalSensors = 0; + const errorCounts: Record = {}; + + setupEvents.forEach(event => { + const props = event.properties as SensorSetupCompleteProperties; + totalSuccess += props.successCount; + totalDuration += props.totalDuration; + totalSensors += props.totalSensors; + }); + + // Collect errors from step events + const stepEvents = this.getEventsInRange(startTime, now).filter( + e => e.name === 'sensor_setup_step' && e.properties?.stepStatus === 'failed' + ); + + stepEvents.forEach(event => { + const error = event.properties?.error || 'Unknown error'; + errorCounts[error] = (errorCounts[error] || 0) + 1; + }); + + const commonErrors = Object.entries(errorCounts) + .map(([error, count]) => ({ error, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + return { + totalSetups, + successRate: totalSensors > 0 ? (totalSuccess / totalSensors) * 100 : 0, + averageDuration: totalSetups > 0 ? totalDuration / totalSetups : 0, + averageSensorsPerSetup: totalSetups > 0 ? totalSensors / totalSetups : 0, + commonErrors, + }; + } +} + +// Export singleton instance +export const analytics = new AnalyticsService();