Skip to content

Commit d3b4423

Browse files
authored
WS-1214: Offline tracking - network type and network status (#13330)
* feat: useConnectionEventTracker * feat: debounce network status change * refactor: update unit test; useCustomEventTracker * chore: cleanup * feat: useConnectionBackOnlineTracker * refactor: useConnectionBackOnlineTracker default value * test: useConnectionTypeTracker * chore: cleanup * test: useConnectionBackOnlineTracker
1 parent 0ee15e3 commit d3b4423

File tree

9 files changed

+271
-18
lines changed

9 files changed

+271
-18
lines changed

src/app/components/ATIAnalytics/canonical/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import addInlineScript, {
1111
} from '#app/lib/utilities/addInlineScript';
1212
import usePWAInstallTracker from '#app/hooks/usePWAInstallTracker';
1313
import { reverbUrlHelper } from '@bbc/reverb-url-helper';
14+
import useConnectionBackOnlineTracker from '#app/hooks/useConnectionBackOnlineTracker';
15+
import useConnectionTypeTracker from '#app/hooks/useConnectionTypeTracker';
1416
import { ATIAnalyticsProps } from '../types';
1517
import getNoScriptTrackingPixelUrl from './getNoScriptTrackingPixelUrl';
1618
import sendPageViewBeaconOperaMini from './sendPageViewBeaconOperaMini';
@@ -48,6 +50,9 @@ const CanonicalATIAnalytics = ({
4850

4951
usePWAInstallTracker();
5052

53+
useConnectionTypeTracker();
54+
useConnectionBackOnlineTracker();
55+
5156
const atiPageViewUrlString =
5257
getEnvConfig().SIMORGH_ATI_BASE_URL + pageviewParams;
5358

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { renderHook } from '@testing-library/react';
2+
import useConnectionBackOnlineTracker from '.';
3+
import * as useCustomEventTrackerModule from '../useCustomEventTracker';
4+
import useNetworkStatusTracker from '../useNetworkStatusTracker';
5+
6+
jest.mock('../useNetworkStatusTracker', () => ({
7+
__esModule: true,
8+
default: jest.fn(() => ({ isOnline: true, networkType: 'unknown' })),
9+
}));
10+
11+
jest.mock('../useTrackingToggle', () => ({
12+
__esModule: true,
13+
default: jest.fn(() => ({ trackingIsEnabled: true })),
14+
}));
15+
16+
const mockTrackEvent = jest.fn();
17+
const mockUseCustomEventTracker = jest.spyOn(
18+
useCustomEventTrackerModule,
19+
'default',
20+
);
21+
22+
describe('useConnectionBackOnlineTracker', () => {
23+
let originalVisibility: PropertyDescriptor | undefined;
24+
25+
beforeEach(() => {
26+
jest.clearAllMocks();
27+
mockUseCustomEventTracker.mockReturnValue(mockTrackEvent);
28+
29+
// Ensure page is considered visible for tracking to fire
30+
originalVisibility = Object.getOwnPropertyDescriptor(
31+
document,
32+
'visibilityState',
33+
);
34+
Object.defineProperty(document, 'visibilityState', {
35+
value: 'visible',
36+
configurable: true,
37+
});
38+
});
39+
40+
afterEach(() => {
41+
if (originalVisibility) {
42+
Object.defineProperty(document, 'visibilityState', originalVisibility);
43+
}
44+
});
45+
46+
it('should initialize useCustomEventTracker with correct eventName', () => {
47+
renderHook(() => useConnectionBackOnlineTracker());
48+
49+
expect(mockUseCustomEventTracker).toHaveBeenCalledWith({
50+
eventName: 'network-connection-back-online',
51+
});
52+
});
53+
54+
it('should track event when offline -> online transition happens', () => {
55+
const networkStatusMock = useNetworkStatusTracker as jest.Mock;
56+
57+
// Initial render: offline
58+
networkStatusMock.mockImplementation(() => ({
59+
isOnline: false,
60+
networkType: '3g',
61+
}));
62+
63+
const { rerender } = renderHook(() => useConnectionBackOnlineTracker());
64+
65+
expect(mockTrackEvent).not.toHaveBeenCalled();
66+
67+
// Rerender: transition to online with new network type
68+
networkStatusMock.mockImplementation(() => ({
69+
isOnline: true,
70+
networkType: '4g',
71+
}));
72+
73+
rerender();
74+
75+
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
76+
expect(mockTrackEvent).toHaveBeenCalledWith('4g');
77+
});
78+
79+
it('should not track event when offline -> online transition happens if page is not visible', () => {
80+
const networkStatusMock = useNetworkStatusTracker as jest.Mock;
81+
82+
networkStatusMock.mockImplementation(() => ({
83+
isOnline: false,
84+
networkType: '3g',
85+
}));
86+
87+
const { rerender } = renderHook(() => useConnectionBackOnlineTracker());
88+
89+
expect(mockTrackEvent).not.toHaveBeenCalled();
90+
91+
Object.defineProperty(document, 'visibilityState', {
92+
value: 'hidden',
93+
configurable: true,
94+
});
95+
96+
networkStatusMock.mockImplementation(() => ({
97+
isOnline: true,
98+
networkType: '4g',
99+
}));
100+
101+
rerender();
102+
103+
expect(mockTrackEvent).not.toHaveBeenCalled();
104+
});
105+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useEffect, useRef, useCallback } from 'react';
2+
import useNetworkStatusTracker from '../useNetworkStatusTracker';
3+
import useCustomEventTracker from '../useCustomEventTracker';
4+
import { NetworkStatus } from '../useNetworkStatusTracker/type';
5+
6+
const BACK_ONLINE_EVENT_NAME = 'network-connection-back-online';
7+
const DEBOUNCE_DELAY = 10000;
8+
9+
/**
10+
* A hook for tracking network status transition from offline to online.
11+
*/
12+
const useConnectionBackOnlineTracker = () => {
13+
const { isOnline, networkType } = useNetworkStatusTracker();
14+
15+
const trackBackOnlineEvent = useCustomEventTracker({
16+
eventName: BACK_ONLINE_EVENT_NAME,
17+
});
18+
19+
const prevIsOnlineRef = useRef(true);
20+
const lastBackOnlineEventTimeRef = useRef(0);
21+
22+
const handleStatusChange = useCallback(
23+
({ isOnline: currentIsOnline, networkType: type }: NetworkStatus) => {
24+
const wasOnline = prevIsOnlineRef.current;
25+
26+
if (wasOnline === currentIsOnline) {
27+
return;
28+
}
29+
30+
const now = Date.now();
31+
const timeSinceLastBackOnline = now - lastBackOnlineEventTimeRef.current;
32+
const isPageVisible =
33+
typeof document !== 'undefined' &&
34+
document.visibilityState === 'visible';
35+
36+
// Only track when transitioning from offline -> online and page is visible
37+
if (
38+
!wasOnline &&
39+
currentIsOnline &&
40+
isPageVisible &&
41+
timeSinceLastBackOnline >= DEBOUNCE_DELAY
42+
) {
43+
lastBackOnlineEventTimeRef.current = now;
44+
trackBackOnlineEvent(type);
45+
}
46+
47+
prevIsOnlineRef.current = currentIsOnline;
48+
},
49+
[trackBackOnlineEvent],
50+
);
51+
52+
useEffect(() => {
53+
handleStatusChange({ isOnline, networkType });
54+
}, [handleStatusChange, isOnline, networkType]);
55+
};
56+
57+
export default useConnectionBackOnlineTracker;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { renderHook } from '@testing-library/react';
2+
import useConnectionTypeTracker from '.';
3+
import useCustomEventTracker from '../useCustomEventTracker';
4+
import useNetworkStatusTracker from '../useNetworkStatusTracker';
5+
6+
jest.mock('../useTrackingToggle', () => ({
7+
__esModule: true,
8+
default: jest.fn(() => ({ trackingIsEnabled: true })),
9+
}));
10+
11+
jest.mock('../useNetworkStatusTracker', () => ({
12+
__esModule: true,
13+
default: jest.fn(() => ({ networkType: '3g' })),
14+
}));
15+
16+
const mockTrackEvent = jest.fn();
17+
jest.mock('../useCustomEventTracker', () => ({
18+
__esModule: true,
19+
default: jest.fn(() => mockTrackEvent),
20+
}));
21+
22+
describe('useConnectionTypeTracker', () => {
23+
beforeEach(() => {
24+
mockTrackEvent.mockClear();
25+
});
26+
27+
it('should initialize useCustomEventTracker with correct eventName', () => {
28+
renderHook(() => useConnectionTypeTracker());
29+
30+
expect(useCustomEventTracker).toHaveBeenCalledWith({
31+
eventName: 'network-effective-type',
32+
});
33+
});
34+
35+
it('should use correct connection type as event payload', () => {
36+
renderHook(() => useConnectionTypeTracker());
37+
38+
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
39+
expect(mockTrackEvent).toHaveBeenCalledWith('3g');
40+
});
41+
42+
it('should only track once', () => {
43+
const networkStatusMock = useNetworkStatusTracker as jest.Mock;
44+
networkStatusMock.mockImplementation(() => ({ networkType: '3g' }));
45+
46+
const { rerender } = renderHook(() => useConnectionTypeTracker());
47+
48+
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
49+
expect(mockTrackEvent).toHaveBeenCalledWith('3g');
50+
51+
rerender();
52+
53+
networkStatusMock.mockImplementation(() => ({ networkType: '4g' }));
54+
55+
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
56+
expect(mockTrackEvent).not.toHaveBeenCalledWith('4g');
57+
});
58+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useEffect, useRef } from 'react';
2+
import useNetworkStatusTracker from '../useNetworkStatusTracker';
3+
import useCustomEventTracker from '../useCustomEventTracker';
4+
5+
const NETWORK_TYPE_EVENT_NAME = 'network-effective-type';
6+
7+
/**
8+
* A hook to track connection type using connection.effectiveType property
9+
*/
10+
const useConnectionTypeTracker = () => {
11+
const networkStatus = useNetworkStatusTracker();
12+
const hasTrackedRef = useRef(false);
13+
14+
const trackNetworkTypeEvent = useCustomEventTracker({
15+
eventName: NETWORK_TYPE_EVENT_NAME,
16+
});
17+
18+
useEffect(() => {
19+
if (hasTrackedRef.current) return;
20+
21+
if (networkStatus.networkType) {
22+
trackNetworkTypeEvent(networkStatus.networkType);
23+
hasTrackedRef.current = true;
24+
}
25+
}, [networkStatus.networkType, trackNetworkTypeEvent]);
26+
};
27+
28+
export default useConnectionTypeTracker;

src/app/hooks/useCustomEventTracker/index.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ describe('useCustomEventTracker', () => {
9999
});
100100

101101
await act(async () => {
102-
await result.current.trackEvent('');
102+
await result.current();
103103
});
104104

105105
expect(mockSendEventBeacon).toHaveBeenCalledTimes(1);
@@ -127,7 +127,7 @@ describe('useCustomEventTracker', () => {
127127
});
128128

129129
await act(async () => {
130-
await result.current.trackEvent(stringifiedData);
130+
await result.current(stringifiedData);
131131
});
132132

133133
expect(mockSendEventBeacon).toHaveBeenCalledWith(
@@ -150,7 +150,7 @@ describe('useCustomEventTracker', () => {
150150
});
151151

152152
await act(async () => {
153-
await result.current.trackEvent('');
153+
await result.current();
154154
});
155155

156156
expect(mockSendEventBeacon).not.toHaveBeenCalled();
@@ -165,7 +165,7 @@ describe('useCustomEventTracker', () => {
165165
);
166166

167167
await act(async () => {
168-
await result.current.trackEvent('');
168+
await result.current();
169169
});
170170

171171
expect(mockSendEventBeacon).not.toHaveBeenCalled();
@@ -179,7 +179,7 @@ describe('useCustomEventTracker', () => {
179179
});
180180

181181
await act(async () => {
182-
await result.current.trackEvent('');
182+
await result.current();
183183
});
184184

185185
expect(mockSendEventBeacon).not.toHaveBeenCalled();

src/app/hooks/useCustomEventTracker/index.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,20 @@ interface CustomEventData {
99
eventName: string;
1010
}
1111

12+
type TrackEventFunction = (stringifiedData?: string) => Promise<void>;
13+
1214
/**
1315
* A specialized React hook for tracking custom (non-click, non-view) events.
1416
* Reverb is used to send the beacon. The event will appear in Piano under the "Event - Group" field.
1517
* If a payload (`stringifiedData`) is provided to the `trackEvent` function, it will appear in Piano under the "Item name" field.
1618
*
1719
* @param {CustomEventData} eventName - A string representing the name of the custom event.
18-
* @returns {Object} An object containing the `trackEvent` function, which can be called to trigger the event.
20+
* @returns {TrackEventFunction} A function that triggers the custom event. Accepts an optional stringified data parameter.
1921
*/
2022

21-
const useCustomEventTracker = ({ eventName }: CustomEventData) => {
23+
const useCustomEventTracker = ({
24+
eventName,
25+
}: CustomEventData): TrackEventFunction => {
2226
const {
2327
pageIdentifier,
2428
producerId,
@@ -34,7 +38,7 @@ const useCustomEventTracker = ({ eventName }: CustomEventData) => {
3438
const { service, useReverb } = use(ServiceContext);
3539

3640
const trackEvent = useCallback(
37-
async (stringifiedData: string) => {
41+
async (stringifiedData = '') => {
3842
if (!trackingIsEnabled || !eventName) return;
3943

4044
const shouldSendEvent = [
@@ -83,9 +87,7 @@ const useCustomEventTracker = ({ eventName }: CustomEventData) => {
8387
],
8488
);
8589

86-
return {
87-
trackEvent,
88-
};
90+
return trackEvent;
8991
};
9092

9193
export default useCustomEventTracker;

src/app/hooks/usePWAInstallTracker/index.test.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@ describe('usePWAInstallTracker', () => {
1515
addEventListenerSpy = jest.spyOn(window, 'addEventListener');
1616

1717
jest.clearAllMocks();
18-
mockUseCustomEventTracker.mockReturnValue({
19-
trackEvent: mockTrackEvent,
20-
});
18+
mockUseCustomEventTracker.mockReturnValue(mockTrackEvent);
2119
});
2220

2321
afterEach(() => {
@@ -51,7 +49,7 @@ describe('usePWAInstallTracker', () => {
5149
addedHandler();
5250

5351
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
54-
expect(mockTrackEvent).toHaveBeenCalledWith('');
52+
expect(mockTrackEvent).toHaveBeenCalledWith();
5553
});
5654

5755
it('should only track the event once even if appinstalled event is fired multiple times', () => {
@@ -65,6 +63,6 @@ describe('usePWAInstallTracker', () => {
6563
addedHandler();
6664

6765
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
68-
expect(mockTrackEvent).toHaveBeenCalledWith('');
66+
expect(mockTrackEvent).toHaveBeenCalledWith();
6967
});
7068
});

0 commit comments

Comments
 (0)