diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx index 9358bd055..b2c7d531c 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx @@ -33,6 +33,7 @@ import { useAdaptiveCameraConfig, useBadConnectionWarning, useTracking, + useCaptureDuration, } from '../hooks'; import { useComplianceAnalytics, @@ -42,6 +43,7 @@ import { usePhotoCaptureSightTutorial, useInspectionComplete, } from './hooks'; +// import { SessionTimeTrackerDemo } from '../components/SessionTimeTrackerDemo'; /** * Props of the PhotoCapture component. @@ -231,11 +233,17 @@ export function PhotoCapture({ tasksBySight, onPictureTaken, }); + const { updateDuration } = useCaptureDuration({ + inspectionId, + apiConfig, + isInspectionCompleted: sightState.isInspectionCompleted, + }); const { handleInspectionCompleted } = useInspectionComplete({ startTasks, sightState, loading, startTasksOnComplete, + onUpdateDuration: updateDuration, onComplete, }); const handleGalleryBack = () => setCurrentScreen(PhotoCaptureScreen.CAMERA); diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/useInspectionComplete.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/useInspectionComplete.ts index 031c259d1..ad29d03e6 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/hooks/useInspectionComplete.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/useInspectionComplete.ts @@ -23,6 +23,10 @@ export interface InspectionCompleteParams * Global loading state of the PhotoCapture component. */ loading: LoadingState; + /** + * Callback called when the user updates the duration of the inspection capture. + */ + onUpdateDuration: (forceUpdate?: boolean) => Promise; /** * Callback called when the user clicks on the "Complete" button in the HUD. */ @@ -47,15 +51,19 @@ export function useInspectionComplete({ sightState, loading, startTasksOnComplete, + onUpdateDuration, onComplete, }: InspectionCompleteParams): InspectionCompleteHandle { const analytics = useAnalytics(); const monitoring = useMonitoring(); - const handleInspectionCompleted = useCallback(() => { + const handleInspectionCompleted = useCallback(async () => { + const updatedDuration = await onUpdateDuration(true); startTasks() .then(() => { - analytics.trackEvent('Capture Completed'); + analytics.trackEvent('Capture Completed', { + capture_duration: updatedDuration, + }); analytics.setUserProperties({ captureCompleted: true, sightSelected: 'inspection-completed', diff --git a/packages/inspection-capture-web/src/hooks/index.ts b/packages/inspection-capture-web/src/hooks/index.ts index 178a39fbe..3505fb9aa 100644 --- a/packages/inspection-capture-web/src/hooks/index.ts +++ b/packages/inspection-capture-web/src/hooks/index.ts @@ -8,3 +8,4 @@ export * from './usePhotoCaptureImages'; export * from './useBadConnectionWarning'; export * from './useAdaptiveCameraConfig'; export * from './useTracking'; +export * from './useCaptureDuration'; diff --git a/packages/inspection-capture-web/src/hooks/useCaptureDuration.ts b/packages/inspection-capture-web/src/hooks/useCaptureDuration.ts new file mode 100644 index 000000000..cae34df03 --- /dev/null +++ b/packages/inspection-capture-web/src/hooks/useCaptureDuration.ts @@ -0,0 +1,203 @@ +import { useObjectMemo } from '@monkvision/common'; +import { MonkApiConfig, useMonkApi } from '@monkvision/network'; +import { useEffect, useRef, useCallback } from 'react'; +import { useMonitoring } from '@monkvision/monitoring'; + +const CAPTURE_DURATION = 'capture_duration'; + +/** + * Parameters of the useCaptureDuration hook. + */ +export interface CaptureDurationParams { + /** + * The inspection ID. + */ + inspectionId: string; + /** + * The api config used to communicate with the API. + */ + apiConfig: MonkApiConfig; + /** + * Boolean indicating if the inspection is completed or not. + */ + isInspectionCompleted: boolean; + /** + * Interval in milliseconds for the heartbeat to update the duration. + */ + heartbeatInterval?: number; + /** + * Idle timeout in milliseconds to pause the capture duration tracking. + */ + idleTimeout?: number; +} + +/** + * Handle used to manage the capture duration. + */ +export interface HandleCaptureDuration { + /** + * Callback to update the capture duration in the API. + */ + updateDuration: () => Promise; +} + +/** + * Custom hook used to track the duration of an inspection session. + */ +export function useCaptureDuration({ + apiConfig, + inspectionId, + isInspectionCompleted, + heartbeatInterval = 30000, + idleTimeout = 10000, +}: CaptureDurationParams): HandleCaptureDuration { + const startTimeRef = useRef(Date.now()); + const totalActiveTimeRef = useRef(0); + const heartbeatTimerRef = useRef(null); + const idleTimerRef = useRef(null); + const pendingUpdateRef = useRef(false); + const captureDurationRef = useRef(0); + const isActive = useRef(true); + const cycleCountRef = useRef(0); + + const { updateAdditionalData } = useMonkApi(apiConfig); + const { handleError } = useMonitoring(); + + const pauseTracking = useCallback((): void => { + if (isActive.current) { + isActive.current = false; + totalActiveTimeRef.current += (Date.now() - startTimeRef.current) / 1000; + } + }, []); + + const resumeTracking = useCallback((): void => { + if (!isActive.current) { + startTimeRef.current = Date.now(); + isActive.current = true; + } + }, []); + + const updateDuration = useCallback( + async (forceUpdate = false): Promise => { + if (isInspectionCompleted || pendingUpdateRef.current) { + return 0; + } + if (forceUpdate) { + totalActiveTimeRef.current += (Date.now() - startTimeRef.current) / 1000; + } + try { + pendingUpdateRef.current = true; + let existingDuration = 0; + await updateAdditionalData({ + id: inspectionId, + callback: (existingData) => { + existingDuration = existingData?.[CAPTURE_DURATION] + ? (existingData?.[CAPTURE_DURATION] as number) + : 0; + return { + ...existingData, + capture_duration: existingDuration + totalActiveTimeRef.current, + }; + }, + }); + captureDurationRef.current = existingDuration + totalActiveTimeRef.current; + startTimeRef.current = Date.now(); + totalActiveTimeRef.current = 0; + return captureDurationRef.current; + } catch (err) { + handleError(err); + throw err; + } finally { + pendingUpdateRef.current = false; + } + }, + [updateAdditionalData, inspectionId, isInspectionCompleted, handleError], + ); + + const restartIdleTimer = useCallback(() => { + if (idleTimerRef.current) { + clearInterval(idleTimerRef.current); + } + idleTimerRef.current = setInterval(() => { + pauseTracking(); + }, idleTimeout); + }, [pauseTracking]); + + useEffect(() => { + if (isInspectionCompleted) { + return undefined; + } + const activityEvents: (keyof DocumentEventMap | keyof WindowEventMap)[] = [ + 'touchstart', + 'touchmove', + 'touchend', + 'click', + 'scroll', + 'keydown', + 'mousedown', + 'mousemove', + ]; + + const handleActivity = (): void => { + if (!isActive.current) { + cycleCountRef.current += 1; + } + resumeTracking(); + restartIdleTimer(); + if (cycleCountRef.current >= 5) { + updateDuration(true); + cycleCountRef.current = 0; + } + }; + + const handleBeforeUnload = (): void => { + updateDuration(); + }; + + const handleVisibilityChange = (): void => { + if (document.hidden) { + updateDuration(); + pauseTracking(); + } else { + resumeTracking(); + } + }; + + heartbeatTimerRef.current = setInterval(() => { + if (isActive.current) { + updateDuration(true); + } + }, heartbeatInterval); + + restartIdleTimer(); + + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('beforeunload', handleBeforeUnload); + activityEvents.forEach((event) => { + document.addEventListener(event, handleActivity, { passive: true }); + }); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('beforeunload', handleBeforeUnload); + activityEvents.forEach((event) => { + document.removeEventListener(event, handleActivity); + }); + if (heartbeatTimerRef.current) { + clearInterval(heartbeatTimerRef.current); + } + if (idleTimerRef.current) { + clearInterval(idleTimerRef.current); + } + }; + }, [ + pauseTracking, + resumeTracking, + updateDuration, + heartbeatInterval, + isInspectionCompleted, + restartIdleTimer, + ]); + + return useObjectMemo({ updateDuration }); +} diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx index 2b8c87ecb..62c676010 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx @@ -32,6 +32,7 @@ import { usePhotoCaptureImages, usePictureTaken, useUploadQueue, + useCaptureDuration, } from '../../src/hooks'; const { CaptureMode } = jest.requireActual('../../src/types'); @@ -100,6 +101,9 @@ jest.mock('../../src/hooks', () => ({ }, })), useTracking: jest.fn(), + useCaptureDuration: jest.fn(() => ({ + updateDuration: jest.fn(), + })), })); function createProps(): PhotoCaptureProps { @@ -317,10 +321,13 @@ describe('PhotoCapture component', () => { const sightState = (usePhotoCaptureSightState as jest.Mock).mock.results[0].value; expect(useLoadingState).toHaveBeenCalled(); const loading = (useLoadingState as jest.Mock).mock.results[0].value; + expect(useCaptureDuration).toHaveBeenCalled(); + const duration = (useCaptureDuration as jest.Mock).mock.results[0].value; expect(useInspectionComplete).toHaveBeenCalledWith({ startTasksOnComplete: props.startTasksOnComplete, startTasks, sightState, + onUpdateDuration: duration.updateDuration, loading, onComplete: props.onComplete, });