Skip to content

Commit 866216f

Browse files
authored
Added capture duration tracker (#934)
1 parent 822d1db commit 866216f

File tree

5 files changed

+229
-2
lines changed

5 files changed

+229
-2
lines changed

packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
useAdaptiveCameraConfig,
3434
useBadConnectionWarning,
3535
useTracking,
36+
useCaptureDuration,
3637
} from '../hooks';
3738
import {
3839
useComplianceAnalytics,
@@ -42,6 +43,7 @@ import {
4243
usePhotoCaptureSightTutorial,
4344
useInspectionComplete,
4445
} from './hooks';
46+
// import { SessionTimeTrackerDemo } from '../components/SessionTimeTrackerDemo';
4547

4648
/**
4749
* Props of the PhotoCapture component.
@@ -231,11 +233,17 @@ export function PhotoCapture({
231233
tasksBySight,
232234
onPictureTaken,
233235
});
236+
const { updateDuration } = useCaptureDuration({
237+
inspectionId,
238+
apiConfig,
239+
isInspectionCompleted: sightState.isInspectionCompleted,
240+
});
234241
const { handleInspectionCompleted } = useInspectionComplete({
235242
startTasks,
236243
sightState,
237244
loading,
238245
startTasksOnComplete,
246+
onUpdateDuration: updateDuration,
239247
onComplete,
240248
});
241249
const handleGalleryBack = () => setCurrentScreen(PhotoCaptureScreen.CAMERA);

packages/inspection-capture-web/src/PhotoCapture/hooks/useInspectionComplete.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export interface InspectionCompleteParams
2323
* Global loading state of the PhotoCapture component.
2424
*/
2525
loading: LoadingState;
26+
/**
27+
* Callback called when the user updates the duration of the inspection capture.
28+
*/
29+
onUpdateDuration: (forceUpdate?: boolean) => Promise<number>;
2630
/**
2731
* Callback called when the user clicks on the "Complete" button in the HUD.
2832
*/
@@ -47,15 +51,19 @@ export function useInspectionComplete({
4751
sightState,
4852
loading,
4953
startTasksOnComplete,
54+
onUpdateDuration,
5055
onComplete,
5156
}: InspectionCompleteParams): InspectionCompleteHandle {
5257
const analytics = useAnalytics();
5358
const monitoring = useMonitoring();
5459

55-
const handleInspectionCompleted = useCallback(() => {
60+
const handleInspectionCompleted = useCallback(async () => {
61+
const updatedDuration = await onUpdateDuration(true);
5662
startTasks()
5763
.then(() => {
58-
analytics.trackEvent('Capture Completed');
64+
analytics.trackEvent('Capture Completed', {
65+
capture_duration: updatedDuration,
66+
});
5967
analytics.setUserProperties({
6068
captureCompleted: true,
6169
sightSelected: 'inspection-completed',

packages/inspection-capture-web/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export * from './usePhotoCaptureImages';
88
export * from './useBadConnectionWarning';
99
export * from './useAdaptiveCameraConfig';
1010
export * from './useTracking';
11+
export * from './useCaptureDuration';
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { useObjectMemo } from '@monkvision/common';
2+
import { MonkApiConfig, useMonkApi } from '@monkvision/network';
3+
import { useEffect, useRef, useCallback } from 'react';
4+
import { useMonitoring } from '@monkvision/monitoring';
5+
6+
const CAPTURE_DURATION = 'capture_duration';
7+
8+
/**
9+
* Parameters of the useCaptureDuration hook.
10+
*/
11+
export interface CaptureDurationParams {
12+
/**
13+
* The inspection ID.
14+
*/
15+
inspectionId: string;
16+
/**
17+
* The api config used to communicate with the API.
18+
*/
19+
apiConfig: MonkApiConfig;
20+
/**
21+
* Boolean indicating if the inspection is completed or not.
22+
*/
23+
isInspectionCompleted: boolean;
24+
/**
25+
* Interval in milliseconds for the heartbeat to update the duration.
26+
*/
27+
heartbeatInterval?: number;
28+
/**
29+
* Idle timeout in milliseconds to pause the capture duration tracking.
30+
*/
31+
idleTimeout?: number;
32+
}
33+
34+
/**
35+
* Handle used to manage the capture duration.
36+
*/
37+
export interface HandleCaptureDuration {
38+
/**
39+
* Callback to update the capture duration in the API.
40+
*/
41+
updateDuration: () => Promise<number>;
42+
}
43+
44+
/**
45+
* Custom hook used to track the duration of an inspection session.
46+
*/
47+
export function useCaptureDuration({
48+
apiConfig,
49+
inspectionId,
50+
isInspectionCompleted,
51+
heartbeatInterval = 30000,
52+
idleTimeout = 10000,
53+
}: CaptureDurationParams): HandleCaptureDuration {
54+
const startTimeRef = useRef<number>(Date.now());
55+
const totalActiveTimeRef = useRef<number>(0);
56+
const heartbeatTimerRef = useRef<NodeJS.Timeout | null>(null);
57+
const idleTimerRef = useRef<NodeJS.Timeout | null>(null);
58+
const pendingUpdateRef = useRef<boolean>(false);
59+
const captureDurationRef = useRef<number>(0);
60+
const isActive = useRef<boolean>(true);
61+
const cycleCountRef = useRef<number>(0);
62+
63+
const { updateAdditionalData } = useMonkApi(apiConfig);
64+
const { handleError } = useMonitoring();
65+
66+
const pauseTracking = useCallback((): void => {
67+
if (isActive.current) {
68+
isActive.current = false;
69+
totalActiveTimeRef.current += (Date.now() - startTimeRef.current) / 1000;
70+
}
71+
}, []);
72+
73+
const resumeTracking = useCallback((): void => {
74+
if (!isActive.current) {
75+
startTimeRef.current = Date.now();
76+
isActive.current = true;
77+
}
78+
}, []);
79+
80+
const updateDuration = useCallback(
81+
async (forceUpdate = false): Promise<number> => {
82+
if (isInspectionCompleted || pendingUpdateRef.current) {
83+
return 0;
84+
}
85+
if (forceUpdate) {
86+
totalActiveTimeRef.current += (Date.now() - startTimeRef.current) / 1000;
87+
}
88+
try {
89+
pendingUpdateRef.current = true;
90+
let existingDuration = 0;
91+
await updateAdditionalData({
92+
id: inspectionId,
93+
callback: (existingData) => {
94+
existingDuration = existingData?.[CAPTURE_DURATION]
95+
? (existingData?.[CAPTURE_DURATION] as number)
96+
: 0;
97+
return {
98+
...existingData,
99+
capture_duration: existingDuration + totalActiveTimeRef.current,
100+
};
101+
},
102+
});
103+
captureDurationRef.current = existingDuration + totalActiveTimeRef.current;
104+
startTimeRef.current = Date.now();
105+
totalActiveTimeRef.current = 0;
106+
return captureDurationRef.current;
107+
} catch (err) {
108+
handleError(err);
109+
throw err;
110+
} finally {
111+
pendingUpdateRef.current = false;
112+
}
113+
},
114+
[updateAdditionalData, inspectionId, isInspectionCompleted, handleError],
115+
);
116+
117+
const restartIdleTimer = useCallback(() => {
118+
if (idleTimerRef.current) {
119+
clearInterval(idleTimerRef.current);
120+
}
121+
idleTimerRef.current = setInterval(() => {
122+
pauseTracking();
123+
}, idleTimeout);
124+
}, [pauseTracking]);
125+
126+
useEffect(() => {
127+
if (isInspectionCompleted) {
128+
return undefined;
129+
}
130+
const activityEvents: (keyof DocumentEventMap | keyof WindowEventMap)[] = [
131+
'touchstart',
132+
'touchmove',
133+
'touchend',
134+
'click',
135+
'scroll',
136+
'keydown',
137+
'mousedown',
138+
'mousemove',
139+
];
140+
141+
const handleActivity = (): void => {
142+
if (!isActive.current) {
143+
cycleCountRef.current += 1;
144+
}
145+
resumeTracking();
146+
restartIdleTimer();
147+
if (cycleCountRef.current >= 5) {
148+
updateDuration(true);
149+
cycleCountRef.current = 0;
150+
}
151+
};
152+
153+
const handleBeforeUnload = (): void => {
154+
updateDuration();
155+
};
156+
157+
const handleVisibilityChange = (): void => {
158+
if (document.hidden) {
159+
updateDuration();
160+
pauseTracking();
161+
} else {
162+
resumeTracking();
163+
}
164+
};
165+
166+
heartbeatTimerRef.current = setInterval(() => {
167+
if (isActive.current) {
168+
updateDuration(true);
169+
}
170+
}, heartbeatInterval);
171+
172+
restartIdleTimer();
173+
174+
document.addEventListener('visibilitychange', handleVisibilityChange);
175+
window.addEventListener('beforeunload', handleBeforeUnload);
176+
activityEvents.forEach((event) => {
177+
document.addEventListener(event, handleActivity, { passive: true });
178+
});
179+
180+
return () => {
181+
document.removeEventListener('visibilitychange', handleVisibilityChange);
182+
window.removeEventListener('beforeunload', handleBeforeUnload);
183+
activityEvents.forEach((event) => {
184+
document.removeEventListener(event, handleActivity);
185+
});
186+
if (heartbeatTimerRef.current) {
187+
clearInterval(heartbeatTimerRef.current);
188+
}
189+
if (idleTimerRef.current) {
190+
clearInterval(idleTimerRef.current);
191+
}
192+
};
193+
}, [
194+
pauseTracking,
195+
resumeTracking,
196+
updateDuration,
197+
heartbeatInterval,
198+
isInspectionCompleted,
199+
restartIdleTimer,
200+
]);
201+
202+
return useObjectMemo({ updateDuration });
203+
}

packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
usePhotoCaptureImages,
3333
usePictureTaken,
3434
useUploadQueue,
35+
useCaptureDuration,
3536
} from '../../src/hooks';
3637

3738
const { CaptureMode } = jest.requireActual('../../src/types');
@@ -100,6 +101,9 @@ jest.mock('../../src/hooks', () => ({
100101
},
101102
})),
102103
useTracking: jest.fn(),
104+
useCaptureDuration: jest.fn(() => ({
105+
updateDuration: jest.fn(),
106+
})),
103107
}));
104108

105109
function createProps(): PhotoCaptureProps {
@@ -317,10 +321,13 @@ describe('PhotoCapture component', () => {
317321
const sightState = (usePhotoCaptureSightState as jest.Mock).mock.results[0].value;
318322
expect(useLoadingState).toHaveBeenCalled();
319323
const loading = (useLoadingState as jest.Mock).mock.results[0].value;
324+
expect(useCaptureDuration).toHaveBeenCalled();
325+
const duration = (useCaptureDuration as jest.Mock).mock.results[0].value;
320326
expect(useInspectionComplete).toHaveBeenCalledWith({
321327
startTasksOnComplete: props.startTasksOnComplete,
322328
startTasks,
323329
sightState,
330+
onUpdateDuration: duration.updateDuration,
324331
loading,
325332
onComplete: props.onComplete,
326333
});

0 commit comments

Comments
 (0)