Add sensor setup analytics tracking
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 <noreply@anthropic.com>
This commit is contained in:
parent
d289dd79a1
commit
1e9ebd14ff
@ -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<Set<string>>(new Set());
|
||||
const [scanStartTime, setScanStartTime] = useState<number | null>(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,
|
||||
|
||||
@ -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<number | null>(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<SensorSetupStep['status'], 'started' | 'completed' | 'failed'> = {
|
||||
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
|
||||
|
||||
358
services/__tests__/analytics.test.ts
Normal file
358
services/__tests__/analytics.test.ts
Normal file
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
260
services/analytics.ts
Normal file
260
services/analytics.ts
Normal file
@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<string, any>): 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<string, number> = {};
|
||||
|
||||
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();
|
||||
Loading…
x
Reference in New Issue
Block a user