Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed exam remaining time display offset #140

Merged
merged 9 commits into from
Mar 4, 2024
5 changes: 3 additions & 2 deletions src/data/slice.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createSlice } from '@reduxjs/toolkit';
import { aggregateActiveAttemptData } from '../helpers';

/* eslint-disable no-param-reassign */
export const examSlice = createSlice({
Expand Down Expand Up @@ -76,10 +77,10 @@ export const examSlice = createSlice({
},
setExamState: (state, { payload }) => {
state.exam = payload.exam;
state.activeAttempt = payload.activeAttempt;
state.activeAttempt = aggregateActiveAttemptData(payload.activeAttempt);
},
setActiveAttempt: (state, { payload }) => {
state.activeAttempt = payload.activeAttempt;
state.activeAttempt = aggregateActiveAttemptData(payload.activeAttempt);
state.apiErrorMsg = '';
},
setProctoringSettings: (state, { payload }) => {
Expand Down
3 changes: 3 additions & 0 deletions src/data/thunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ export function pollAttempt(url) {

try {
const data = await pollExamAttempt(url);
if (!data) {
throw new Error('Poll Exam failed to fetch.');
rijuma marked this conversation as resolved.
Show resolved Hide resolved
}
const updatedAttempt = {
...currentAttempt,
time_remaining_seconds: data.time_remaining_seconds,
Expand Down
19 changes: 19 additions & 0 deletions src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,22 @@ export const generateHumanizedTime = (timeRemainingSeconds) => {
}
return remainingTime;
};

// The only information we get on the remaining time on the active exam attempt
// from the endpoint is the remaining seconds. We need to have a fixed time reference
// on the time limit to be able to calculate the remaining time accurately.
export const aggregateActiveAttemptData = (activeAttempt) => {
rijuma marked this conversation as resolved.
Show resolved Hide resolved
if (!activeAttempt?.time_remaining_seconds) {
return activeAttempt;
}

const timerEnds = new Date();
timerEnds.setSeconds(timerEnds.getSeconds() + activeAttempt.time_remaining_seconds);

const updatedActiveAttempt = {
...activeAttempt,
timer_ends: timerEnds.toISOString(),
};

return updatedActiveAttempt;
};
80 changes: 53 additions & 27 deletions src/timer/TimerProvider.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { useToggle } from '@edx/paragon';
Expand All @@ -15,6 +15,7 @@ const GRACE_PERIOD_SECS = 5;
const POLL_INTERVAL = 60;
const TIME_LIMIT_CRITICAL_PCT = 0.05;
const TIME_LIMIT_LOW_PCT = 0.2;
const LIMIT = GRACE_PERIOD_SECS ? 0 - GRACE_PERIOD_SECS : 0;

export const TimerContext = React.createContext({});

Expand All @@ -28,20 +29,15 @@ const TimerProvider = ({
children,
}) => {
const { activeAttempt: attempt, exam } = useSelector(state => state.specialExams);
const { time_limit_mins: timeLimitMins } = exam;
const [timeState, setTimeState] = useState({});
const [limitReached, setLimitReached] = useToggle(false);
const dispatch = useDispatch();
const { time_limit_mins: timeLimitMins } = exam;
const {
desktop_application_js_url: workerUrl,
ping_interval: pingInterval,
time_remaining_seconds: timeRemaining,
timer_ends: timerEnds,
} = attempt;
const LIMIT = GRACE_PERIOD_SECS ? 0 - GRACE_PERIOD_SECS : 0;
const CRITICAL_LOW_TIME = timeLimitMins * 60 * TIME_LIMIT_CRITICAL_PCT;
const LOW_TIME = timeLimitMins * 60 * TIME_LIMIT_LOW_PCT;
let liveInterval = null;

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The control variable was moved to the useEffect block, since the component refresh would overwrite this value rendering it useless.

const dispatch = useDispatch();

const getTimeString = () => Object.values(timeState).map(
item => {
Expand All @@ -52,15 +48,19 @@ const TimerProvider = ({
},
).join(':');

const pollExam = () => {
const pollExam = useCallback(() => {
// poll url may be null if this is an LTI exam
rijuma marked this conversation as resolved.
Show resolved Hide resolved
dispatch(pollAttempt(attempt.exam_started_poll_url));
};
if (attempt?.exam_started_poll_url) {
dispatch(pollAttempt(attempt.exam_started_poll_url));
}
rijuma marked this conversation as resolved.
Show resolved Hide resolved
}, [attempt.exam_started_poll_url, dispatch]);
const processTimeLeft = useCallback(() => (secondsLeft) => {
const criticalLowTime = timeLimitMins * 60 * TIME_LIMIT_CRITICAL_PCT;
rijuma marked this conversation as resolved.
Show resolved Hide resolved
const lowTime = timeLimitMins * 60 * TIME_LIMIT_LOW_PCT;

rijuma marked this conversation as resolved.
Show resolved Hide resolved
const processTimeLeft = (timer, secondsLeft) => {
if (secondsLeft <= CRITICAL_LOW_TIME) {
if (secondsLeft <= criticalLowTime) {
Emitter.emit(TIMER_IS_CRITICALLY_LOW);
} else if (secondsLeft <= LOW_TIME) {
} else if (secondsLeft <= lowTime) {
Emitter.emit(TIMER_IS_LOW);
}
// Used to hide continue exam button on submit exam pages.
Expand All @@ -69,22 +69,35 @@ const TimerProvider = ({
if (secondsLeft <= 0) {
Emitter.emit(TIMER_REACHED_NULL);
}

if (!limitReached && secondsLeft < LIMIT) {
clearInterval(timer);
setLimitReached();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've moved this to be handled in the interval block, since I find it easier to understand.

Emitter.emit(TIMER_LIMIT_REACHED);

return false; // Stop the time ticker.
}
};

return true;
}, [
timeLimitMins,
limitReached,
setLimitReached,
]);

useEffect(() => {
let timerHandler = null;
let timerTick = 0;
let secondsLeft = Math.floor(timeRemaining);
// eslint-disable-next-line react-hooks/exhaustive-deps
liveInterval = setInterval(() => {
secondsLeft -= 1;
timerTick += 1;
const deadline = new Date(timerEnds);

let timerRef = null;

timerHandler = setInterval(() => {
timerTick++;
const now = new Date();
const remainingTime = (deadline.getTime() - now.getTime()) / 1000;
const secondsLeft = Math.floor(remainingTime);

setTimeState(getFormattedRemainingTime(secondsLeft));
processTimeLeft(liveInterval, secondsLeft);
// no polling during grace period
if (timerTick % POLL_INTERVAL === 0 && secondsLeft >= 0) {
pollExam();
Expand All @@ -93,14 +106,27 @@ const TimerProvider = ({
if (workerUrl && timerTick % pingInterval === pingInterval / 2) {
dispatch(pingAttempt(pingInterval, workerUrl));
}

const keepRunning = processTimeLeft(secondsLeft);
if (!keepRunning) {
rijuma marked this conversation as resolved.
Show resolved Hide resolved
clearInterval(timerHandler);
}
}, 1000);

return () => {
if (liveInterval) {
clearInterval(liveInterval);
liveInterval = null;
if (timerRef) {
clearInterval(timerHandler);
timerRef = null;
}
};
}, [timeRemaining, dispatch]);
}, [
timerEnds,
pingInterval,
workerUrl,
processTimeLeft,
pollExam,
dispatch,
]);

return (
// eslint-disable-next-line react/jsx-no-constructed-context-values
Expand Down
Loading