Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 9 additions & 15 deletions src/components/mobile/MobileQuizManager/QuizCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,7 @@ const QuizCarousel = ({
}
}, [activeIndex, currentQuestionIndex]);

const trackScrollAnalytics = (event: any) => {
const offsetX = event.nativeEvent.contentOffset.x;
const index = Math.round(offsetX / SCREEN_WIDTH);

trackEvent(AnalyticsEvent.PERFORMANCE_METRIC, {
event_category: 'high_frequency',
event_name: 'quiz_carousel_scroll',
offsetX: Math.round(offsetX),
index,
});

isScrollingRef.current = true;
};

const getItemLayout = useCallback(
(_: ArrayLike<Question> | null | undefined, index: number) => ({
Expand All @@ -64,12 +52,20 @@ const QuizCarousel = ({
(event: { nativeEvent: { contentOffset: { x: number } } }) => {
isScrollingRef.current = false;
const index = Math.round(event.nativeEvent.contentOffset.x / SCREEN_WIDTH);

trackEvent(AnalyticsEvent.PERFORMANCE_METRIC, {
event_category: 'high_frequency',
event_name: 'quiz_carousel_scroll',
offsetX: Math.round(event.nativeEvent.contentOffset.x),
index,
});

if (index < 0 || index >= questions.length || index === activeIndex) return;

setActiveIndex(index);
onQuestionChange(index);
},
[activeIndex, onQuestionChange, questions.length]
[activeIndex, onQuestionChange, questions.length, trackEvent]
);

const renderItem = useCallback(
Expand Down Expand Up @@ -99,12 +95,10 @@ const QuizCarousel = ({
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onScroll={trackScrollAnalytics}
onScrollBeginDrag={() => {
isScrollingRef.current = true;
}}
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
decelerationRate="fast"
snapToInterval={SCREEN_WIDTH}
snapToAlignment="center"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import QuizCarousel from '../QuizCarousel';
import { useAnalytics } from '../../../../hooks/useAnalytics';

jest.mock('../../../../hooks/useAnalytics', () => ({
useAnalytics: jest.fn(),
}));

describe('QuizCarousel Analytics', () => {
const mockTrackEvent = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
(useAnalytics as jest.Mock).mockReturnValue({
trackEvent: mockTrackEvent,
});
});

it('fires analytics only once per swipe (onMomentumScrollEnd), not on every frame', () => {
const questions = [
{ id: '1', text: 'Q1', type: 'multiple-choice', options: [] },
{ id: '2', text: 'Q2', type: 'multiple-choice', options: [] },
] as any;

const { getByTestId } = render(
<QuizCarousel
questions={questions}
currentQuestionIndex={0}
selectedAnswers={{}}
onQuestionChange={jest.fn()}
onAnswerSelect={jest.fn()}
/>
);

const flatList = getByTestId('QuizCarouselList');

// Simulate drag start
fireEvent(flatList, 'scrollBeginDrag');

// Simulate scroll end (momentum end) simulating scrolling to 2nd item (index 1)
// Assuming SCREEN_WIDTH is some value, let's pass a simulated offset
fireEvent(flatList, 'momentumScrollEnd', {
nativeEvent: {
contentOffset: { x: 400 }, // some arbitrary width indicating swipe
},
});

expect(mockTrackEvent).toHaveBeenCalledTimes(1);
expect(mockTrackEvent).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
event_category: 'high_frequency',
event_name: 'quiz_carousel_scroll',
}));
});
});
63 changes: 63 additions & 0 deletions src/services/socket/__tests__/backoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { io } from 'socket.io-client';
import SocketService from '../index';
import { useSocketStore } from '../../../store';

jest.mock('socket.io-client', () => {
const mSocket = {
connected: false,
on: jest.fn(),
emit: jest.fn(),
disconnect: jest.fn(),
connect: jest.fn(),
};
return {
io: jest.fn(() => mSocket),
};
});

jest.mock('../../../store', () => ({
useSocketStore: {
getState: jest.fn(() => ({
setReconnectAttempts: jest.fn(),
setConnectionFailed: jest.fn(),
resetConnection: jest.fn(),
})),
},
}));

describe('Socket Reconnection Backoff', () => {
it('configures exponential backoff correctly', () => {
SocketService.connect();

expect(io).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
reconnectionAttempts: 10,
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
randomizationFactor: 0.2, // Gives 0.8 to 1.2 multiplier
}));
});

it('calculates backoff delay sequence according to specifications', () => {
// This tests the math logic that socket.io-client implements internally
// delay = Math.min(1000 * 2^attempt, 30000) * (0.8 + Math.random() * 0.4)
const getBaseDelay = (attempt: number) => Math.min(1000 * Math.pow(2, attempt), 30000);

const attempt0 = getBaseDelay(0);
expect(attempt0).toBe(1000);

const attempt1 = getBaseDelay(1);
expect(attempt1).toBe(2000);

const attempt2 = getBaseDelay(2);
expect(attempt2).toBe(4000);

const attempt3 = getBaseDelay(3);
expect(attempt3).toBe(8000);

const attempt4 = getBaseDelay(4);
expect(attempt4).toBe(16000);

const attempt5 = getBaseDelay(5);
expect(attempt5).toBe(30000); // Capped at 30s
});
});
21 changes: 18 additions & 3 deletions src/services/socket/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { io, Socket } from 'socket.io-client';

import { useSocketStore } from '../../store';
import { decodeBinaryMessage, encodeBinaryMessage } from './binaryProtocol';
import { getEnv } from '../../config';
import { appLogger } from '../../utils/logger';
import syncEntityManager from '../sync/syncEntityManager';

import type { ConflictResolutionStrategy, VersionedSyncMessage } from '../sync/types';

const RECONNECTION_ATTEMPTS = 10;
const RECONNECTION_DELAY_MS = 1_000;
const RECONNECTION_DELAY_MAX_MS = 30_000;

const HEARTBEAT_INTERVAL_MS = 30_000;
const HEARTBEAT_TIMEOUT_MS = 5_000;
Expand All @@ -18,6 +16,7 @@ const BACKOFF_DELAYS = [1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000];

class SocketService {
private socket: Socket | null = null;
private stableConnectionTimeout?: NodeJS.Timeout;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private pongTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
private backoffIndex = 0;
Expand All @@ -43,12 +42,24 @@ class SocketService {
if (transport) {
appLogger.debug(`Socket active transport: ${transport.name}`);
}

// Reset connection state after stable 60s connection
if (this.stableConnectionTimeout) {
clearTimeout(this.stableConnectionTimeout);
}
this.stableConnectionTimeout = setTimeout(() => {
useSocketStore.getState().resetConnection();
}, 60000);

this.backoffIndex = 0;
this.startHeartbeat();
});

this.socket.on('disconnect', (reason: string) => {
appLogger.warn('Socket disconnected:', reason);
if (this.stableConnectionTimeout) {
clearTimeout(this.stableConnectionTimeout);
}
this.stopHeartbeat();
if (!this.intentionalDisconnect && reason !== 'io client disconnect') {
this.scheduleReconnect();
Expand Down Expand Up @@ -118,12 +129,16 @@ class SocketService {

appLogger.info(`Socket reconnecting in ${actualDelay}ms (backoff index: ${this.backoffIndex})`);

useSocketStore.getState().setReconnectAttempts(this.backoffIndex + 1);

this.reconnectTimer = setTimeout(() => {
if (this.socket) {
this.socket.connect();
}
if (this.backoffIndex < BACKOFF_DELAYS.length - 1) {
this.backoffIndex++;
} else {
useSocketStore.getState().setConnectionFailed(true);
}
}, actualDelay);
}
Expand Down
1 change: 1 addition & 0 deletions src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,5 @@ export * from './metricsStore';
export * from './notificationStore';
export * from './reviewStore';
export * from './selectors';
export * from './socketStore';
export * from './syncStore';
17 changes: 17 additions & 0 deletions src/store/socketStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { create } from 'zustand';

interface SocketState {
reconnectAttempts: number;
connectionFailed: boolean;
setReconnectAttempts: (attempts: number) => void;
setConnectionFailed: (failed: boolean) => void;
resetConnection: () => void;
}

export const useSocketStore = create<SocketState>((set) => ({
reconnectAttempts: 0,
connectionFailed: false,
setReconnectAttempts: (attempts) => set({ reconnectAttempts: attempts }),
setConnectionFailed: (failed) => set({ connectionFailed: failed }),
resetConnection: () => set({ reconnectAttempts: 0, connectionFailed: false }),
}));
Loading