From d8444289f7edfa313705bee0134f60c00ba3da74 Mon Sep 17 00:00:00 2001 From: Eilyss Date: Thu, 10 Apr 2025 10:40:28 +0800 Subject: [PATCH] Add custom useTimer hook to track time users spent on pages --- .env.example | 2 +- .vscode/settings.json | 4 +- .../application/actions/SessionActions.ts | 3 +- .../AssessmentWorkspace.tsx | 7 +- src/commons/sagas/BackendSaga.ts | 12 ++- src/commons/sagas/RequestsSaga.ts | 16 ++++ src/commons/utils/Hooks.ts | 94 ++++++++++++++++++- src/pages/playground/Playground.tsx | 13 ++- src/pages/sicp/Sicp.tsx | 5 +- 9 files changed, 145 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index ecc741ea7b..3ce9a3e46c 100644 --- a/.env.example +++ b/.env.example @@ -52,7 +52,7 @@ REACT_APP_NUS_SAML_PROVIDER1_ENDPOINT= REACT_APP_MODULE_BACKEND_URL=https://source-academy.github.io/modules REACT_APP_SHAREDB_BACKEND_URL= -REACT_APP_SICPJS_BACKEND_URL="http://127.0.0.1:8080/" +REACT_APP_SICPJS_BACKEND_URL="https://sicp.sourceacademy.org/" REACT_APP_STORIES_BACKEND_URL=http://localhost:4321 # API keys for Google Drive integration diff --git a/.vscode/settings.json b/.vscode/settings.json index 1f849d81a0..fdf201dbe7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,7 @@ "files.insertFinalNewline": true, "liveServer.settings.file": "/index.html", "liveServer.settings.NoBrowser": true, - "liveServer.settings.root": "/build" + "liveServer.settings.root": "/build", + "editor.tabSize": 2, + "editor.stickyTabStops": false } diff --git a/src/commons/application/actions/SessionActions.ts b/src/commons/application/actions/SessionActions.ts index 1fc5590ce5..514b36c60e 100644 --- a/src/commons/application/actions/SessionActions.ts +++ b/src/commons/application/actions/SessionActions.ts @@ -149,7 +149,8 @@ const SessionActions = createActions('session', { deleteUserCourseRegistration: (courseRegId: number) => ({ courseRegId }), updateCourseResearchAgreement: (agreedToResearch: boolean) => ({ agreedToResearch }), updateStoriesUserRole: (userId: number, role: StoriesRole) => ({ userId, role }), - deleteStoriesUserUserGroups: (userId: number) => ({ userId }) + deleteStoriesUserUserGroups: (userId: number) => ({ userId }), + updateTimeSpent: (path: string, time: number) => ({ path, time }) }); // For compatibility with existing code (actions helper) diff --git a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx index cc3723a158..56bb302e41 100644 --- a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx +++ b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx @@ -70,7 +70,7 @@ import { changeSideContentHeight } from '../sideContent/SideContentActions'; import { useSideContent } from '../sideContent/SideContentHelper'; import { SideContentTab, SideContentType } from '../sideContent/SideContentTypes'; import Constants from '../utils/Constants'; -import { useResponsive, useTypedSelector } from '../utils/Hooks'; +import { useResponsive, useTypedSelector, useTimer } from '../utils/Hooks'; import { assessmentTypeLink } from '../utils/ParamParseHelper'; import { assertType } from '../utils/TypeHelper'; import Workspace, { WorkspaceProps } from '../workspace/Workspace'; @@ -108,9 +108,10 @@ const AssessmentWorkspace: React.FC = props => { ? SideContentType.grading : SideContentType.questionOverview ); - + const assessmentName: string = assessmentOverview ? assessmentOverview.type.toLowerCase() + "/" + assessmentOverview.id : ""; + useTimer(assessmentName); + const navigate = useNavigate(); - const { courseId } = useTypedSelector(state => state.session); const { isFolderModeEnabled, diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index b9c57609b8..3c9bd2ad0b 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -93,6 +93,7 @@ import { putLatestViewedCourse, putNewUsers, putTeams, + postTimeSpent, putUserRole, removeAssessmentConfig, removeUserCourseRegistration, @@ -1033,7 +1034,16 @@ const newBackendSagaTwo = combineSagaHandlers(sagaActions, { yield put(actions.fetchAdminPanelCourseRegistrations()); yield call(showSuccessMessage, 'User deleted!'); - } + }, + updateTimeSpent: function* (action) { + const tokens: Tokens = yield selectTokens(); + const { path, time } = action.payload; + + const resp: Response | null = yield call(postTimeSpent, tokens, path, time); + if (!resp || !resp.ok) { + return yield handleResponseError(resp); + } + }, }); function* oldBackendSagaThree(): SagaIterator { diff --git a/src/commons/sagas/RequestsSaga.ts b/src/commons/sagas/RequestsSaga.ts index 29f88df173..d777630f79 100644 --- a/src/commons/sagas/RequestsSaga.ts +++ b/src/commons/sagas/RequestsSaga.ts @@ -1596,3 +1596,19 @@ export const courseIdWithoutPrefix: () => string = () => { throw new Error(`No course selected`); } }; + +/** + * POST /courses/{courseId}/user/update_time_spent + */ +export const postTimeSpent = async ( + tokens: Tokens, + path: string, + time: number +): Promise => { + const resp = await request(`${courseId()}/user/update_time_spent`, 'POST', { + ...tokens, + body: { path, time } + }); + + return resp; +}; diff --git a/src/commons/utils/Hooks.ts b/src/commons/utils/Hooks.ts index 9aafc00d08..ed90d6bb48 100644 --- a/src/commons/utils/Hooks.ts +++ b/src/commons/utils/Hooks.ts @@ -1,12 +1,17 @@ import { useMediaQuery } from '@mantine/hooks'; -import React, { RefObject } from 'react'; +import React, { RefObject, } from 'react'; // eslint-disable-next-line no-restricted-imports import { TypedUseSelectorHook, useSelector } from 'react-redux'; +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; +import { throttle } from 'lodash'; import { OverallState } from '../application/ApplicationTypes'; import { Tokens } from '../application/types/SessionTypes'; import Constants from './Constants'; import { readLocalStorage, setLocalStorage } from './LocalStorageHelper'; +import SessionActions from '../application/actions/SessionActions'; /** * This hook sends a request to the backend to fetch the initial state of the field @@ -167,3 +172,90 @@ export const useTokens: UseTokens = ({ throwWhenEmpty = true } = {}) => { } return { accessToken, refreshToken } as Tokens; }; + + + +export function useTimer( + path: string | undefined = undefined, + enableIdleTimer: boolean = true, + idleTime: number = 60000, + throttleTime: number = 100, +){ + const dispatch = useDispatch(); + const timerPath: string = path || useLocation().pathname.slice(1); + + const generateTimer = ( + name: string | undefined, + idleTime: number, + enableIdleTimer: boolean = true + ) => { + let start = 0; + let timeElapsed = 0; + let running = false; + let stopWhenIdle: ReturnType; + + const startTimer = () => { + if (enableIdleTimer) { + if (stopWhenIdle) { + clearTimeout(stopWhenIdle); + } + stopWhenIdle = generateIdleTimer(idleTime); + } + if (running) return; + running = true; + start = Date.now(); + }; + + const stopTimer = () => { + if (!running) return; + if (stopWhenIdle) { + clearTimeout(stopWhenIdle); + } + running = false; + timeElapsed += Date.now() - start; + start = Date.now(); + }; + + const generateIdleTimer = (time: number) => { + return setTimeout(() => { + stopTimer(); + }, time); + }; + + return { + name, + startTimer, + stopTimer, + getTimeElapsed: () => Math.floor(timeElapsed / 1000), + } + }; + + const timer = generateTimer(timerPath, idleTime, enableIdleTimer); + const throttledStartTimer = throttle(timer.startTimer, throttleTime); + + const updateGlobalTimer = () => { + timer.stopTimer(); + if (timerPath !== "") { + dispatch(SessionActions.updateTimeSpent(timerPath, timer.getTimeElapsed())); + } + }; + + useEffect(() => { + const globalTimerUpdater = throttle(updateGlobalTimer, 100); + timer.startTimer(); + + document.addEventListener("keydown", throttledStartTimer); + document.addEventListener("mousedown", throttledStartTimer); + document.addEventListener("mousemove", throttledStartTimer); + document.addEventListener("beforeunload", globalTimerUpdater); + + return () => { + globalTimerUpdater(); + + document.removeEventListener("keydown", throttledStartTimer); + document.removeEventListener("mousedown", throttledStartTimer); + document.removeEventListener("mousemove", throttledStartTimer); + document.removeEventListener("beforeunload", globalTimerUpdater); + }; + }, [timerPath]); +} diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 58aeabeebd..c60e94a104 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -25,7 +25,7 @@ import makeHtmlDisplayTabFrom from 'src/commons/sideContent/content/SideContentH import makeUploadTabFrom from 'src/commons/sideContent/content/SideContentUpload'; import { changeSideContentHeight } from 'src/commons/sideContent/SideContentActions'; import { useSideContent } from 'src/commons/sideContent/SideContentHelper'; -import { useResponsive, useTypedSelector } from 'src/commons/utils/Hooks'; +import { useResponsive, useTypedSelector, useTimer } from 'src/commons/utils/Hooks'; import { showFullJSWarningOnUrlLoad, showFulTSWarningOnUrlLoad, @@ -205,6 +205,7 @@ const Playground: React.FC = props => { const store = useStore(); const searchParams = new URLSearchParams(location.search); const shouldAddDevice = searchParams.get('add_device'); + useTimer(); // Selectors and handlers migrated over from deprecated withRouter implementation const { @@ -286,6 +287,16 @@ const Playground: React.FC = props => { chapter: playgroundSourceChapter }) ); + + const trackedContent: (SideContentType)[] = [ + SideContentType.cseMachine, + SideContentType.dataVisualizer, + SideContentType.substVisualizer, + SideContentType.remoteExecution + ]; + + const path = (selectedTab && trackedContent.includes(selectedTab)) ? ("playground/" + selectedTab) : "" + useTimer(path); // Playground hotkeys const [isGreen, setIsGreen] = useState(false); diff --git a/src/pages/sicp/Sicp.tsx b/src/pages/sicp/Sicp.tsx index 73798b81ca..0527cf4500 100644 --- a/src/pages/sicp/Sicp.tsx +++ b/src/pages/sicp/Sicp.tsx @@ -7,7 +7,7 @@ import { useDispatch } from 'react-redux'; import { useLocation, useNavigate, useParams } from 'react-router'; import { Link } from 'react-router-dom'; import Constants from 'src/commons/utils/Constants'; -import { useSession } from 'src/commons/utils/Hooks'; +import { useSession, useTimer } from 'src/commons/utils/Hooks'; import { setLocalStorage } from 'src/commons/utils/LocalStorageHelper'; import WorkspaceActions from 'src/commons/workspace/WorkspaceActions'; import { SicpSection } from 'src/features/sicp/chatCompletion/chatCompletion'; @@ -46,7 +46,8 @@ const Sicp: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); const { isLoggedIn } = useSession(); - + useTimer("sicpjs"); + function getSection() { // To discard the '/sicpjs/' return location.pathname.replace('/sicpjs/', '') as SicpSection;