From e5a559bf57971123b45eb7f377f9fd64487fd0f3 Mon Sep 17 00:00:00 2001 From: David Leclerc Date: Mon, 10 Jun 2024 12:22:25 +0200 Subject: [PATCH] Fix: keep track of question index in timer so client knows when to restart it. --- .../src/components/overlays/AnswerOverlay.tsx | 2 +- Apps/Client/src/hooks/useQuiz.ts | 4 -- ...verCountdownTimer.ts => useServerTimer.ts} | 22 ++++----- .../{useCountdownTimer.ts => useTimer.ts} | 20 ++++---- Apps/Client/src/models/TimeDuration.ts | 4 +- Apps/Client/src/pages/QuizPage.tsx | 49 ++++++++++++------- Apps/Client/src/types/DataTypes.ts | 1 + .../quiz/StartQuestionController.ts | 8 +-- Apps/Server/src/models/Quiz.ts | 5 ++ Apps/Server/src/types/DataTypes.ts | 1 + 10 files changed, 68 insertions(+), 48 deletions(-) rename Apps/Client/src/hooks/{useServerCountdownTimer.ts => useServerTimer.ts} (68%) rename Apps/Client/src/hooks/{useCountdownTimer.ts => useTimer.ts} (86%) diff --git a/Apps/Client/src/components/overlays/AnswerOverlay.tsx b/Apps/Client/src/components/overlays/AnswerOverlay.tsx index b84d490..0ce0e46 100644 --- a/Apps/Client/src/components/overlays/AnswerOverlay.tsx +++ b/Apps/Client/src/components/overlays/AnswerOverlay.tsx @@ -22,7 +22,7 @@ const AnswerOverlay: React.FC = () => { const user = useUser(); const appQuestionIndex = app.questionIndex; - const nextAppQuestionIndex = appQuestionIndex + 1; + const nextAppQuestionIndex = appQuestionIndex + 1; // FIXME: 19/18? const question = useQuestion(appQuestionIndex); const overlay = useOverlay(OverlayName.Answer); diff --git a/Apps/Client/src/hooks/useQuiz.ts b/Apps/Client/src/hooks/useQuiz.ts index 91a97fb..bfd4159 100644 --- a/Apps/Client/src/hooks/useQuiz.ts +++ b/Apps/Client/src/hooks/useQuiz.ts @@ -7,7 +7,6 @@ import { Language, NO_QUESTION_INDEX, NO_VOTE_INDEX, UserType } from '../constan import { useTranslation } from 'react-i18next'; import useApp from './useApp'; import { setQuestionIndex } from '../reducers/AppReducer'; -import { DEBUG } from '../config'; import { sleep } from '../utils/time'; import TimeDuration from '../models/TimeDuration'; import { TimeUnit } from '../types/TimeTypes'; @@ -72,9 +71,6 @@ const useQuiz = () => { // Force user to go to next question according to setting if (status.isNextQuestionForced && app.questionIndex !== questionIndex) { - if (DEBUG) { - console.log(`Forcing user to next question: #${questionIndex + 1}`); - } dispatch(setQuestionIndex(questionIndex)); } }, [status]); diff --git a/Apps/Client/src/hooks/useServerCountdownTimer.ts b/Apps/Client/src/hooks/useServerTimer.ts similarity index 68% rename from Apps/Client/src/hooks/useServerCountdownTimer.ts rename to Apps/Client/src/hooks/useServerTimer.ts index 10e550b..6df1a6f 100644 --- a/Apps/Client/src/hooks/useServerCountdownTimer.ts +++ b/Apps/Client/src/hooks/useServerTimer.ts @@ -1,10 +1,10 @@ import TimeDuration from '../models/TimeDuration'; import { TimeUnit } from '../types/TimeTypes'; -import useCountdownTimer from './useCountdownTimer'; +import useTimer from './useTimer'; import { NO_TIME } from '../constants'; import useQuiz from './useQuiz'; -const useServerCountdownTimer = () => { +const useServerTimer = (interval: TimeDuration = new TimeDuration(1, TimeUnit.Second)) => { const quiz = useQuiz(); const data = quiz.status?.timer; @@ -20,8 +20,8 @@ const useServerCountdownTimer = () => { remainingTime = NO_TIME; } - const timer = useCountdownTimer({ - interval: new TimeDuration(1, TimeUnit.Second), + const localTimer = useTimer({ + interval, duration: remainingTime, autoStart: false, }); @@ -30,13 +30,13 @@ const useServerCountdownTimer = () => { isEnabled, duration, startedAt, - isRunning: timer.isRunning, - isDone: timer.isDone, - time: timer.time, - start: timer.start, - stop: timer.stop, - restart: timer.restart, + isRunning: localTimer.isRunning, + isDone: localTimer.isDone, + time: localTimer.time, + start: localTimer.start, + stop: localTimer.stop, + restart: localTimer.restart, }; }; -export default useServerCountdownTimer; \ No newline at end of file +export default useServerTimer; \ No newline at end of file diff --git a/Apps/Client/src/hooks/useCountdownTimer.ts b/Apps/Client/src/hooks/useTimer.ts similarity index 86% rename from Apps/Client/src/hooks/useCountdownTimer.ts rename to Apps/Client/src/hooks/useTimer.ts index 7e0bbe6..803e746 100644 --- a/Apps/Client/src/hooks/useCountdownTimer.ts +++ b/Apps/Client/src/hooks/useTimer.ts @@ -9,8 +9,9 @@ interface TimerOptions { autoStart?: boolean; // Whether to start the timer immediately } -const useCountdownTimer = ({ duration, interval = new TimeDuration(1, TimeUnit.Second), autoStart = false }: TimerOptions) => { - const [time, setTime] = useState(NO_TIME); +const useTimer = ({ duration, interval = new TimeDuration(1, TimeUnit.Second), autoStart = false }: TimerOptions) => { + const [time, setTime] = useState(duration); + const [isRunning, setIsRunning] = useState(autoStart); const [isDone, setIsDone] = useState(false); const timerRef = useRef(null); @@ -30,7 +31,7 @@ const useCountdownTimer = ({ duration, interval = new TimeDuration(1, TimeUnit.S setIsRunning(true); setIsDone(false); } - }, [isRunning, isDone, duration]); + }, [isRunning, duration]); @@ -49,11 +50,10 @@ const useCountdownTimer = ({ duration, interval = new TimeDuration(1, TimeUnit.S const restart = useCallback(() => { - if (isRunning) { - stop(); - } + stop(); start(); - }, [isRunning, stop, start]); + + }, [stop, start]); @@ -85,13 +85,15 @@ const useCountdownTimer = ({ duration, interval = new TimeDuration(1, TimeUnit.S timerRef.current = null; } }; - }, [isRunning, interval]); + }, [isRunning, time, interval]); // Handle timer stop (clean up interval) useEffect(() => { if (!isRunning && timerRef.current) { + console.warn(`Inconsistent state in timer. Resetting it...`); + setIsRunning(false); setIsDone(false); @@ -114,4 +116,4 @@ const useCountdownTimer = ({ duration, interval = new TimeDuration(1, TimeUnit.S return { time, isRunning, isDone, start, stop, restart }; }; -export default useCountdownTimer; \ No newline at end of file +export default useTimer; \ No newline at end of file diff --git a/Apps/Client/src/models/TimeDuration.ts b/Apps/Client/src/models/TimeDuration.ts index 13f7427..90b6de1 100644 --- a/Apps/Client/src/models/TimeDuration.ts +++ b/Apps/Client/src/models/TimeDuration.ts @@ -25,8 +25,8 @@ class TimeDuration implements Comparable { return this.toMs().getAmount(); } - public static deserialize(str: string) { - return new TimeDuration(parseInt(str, 10), TimeUnit.Millisecond); + public static deserialize(amount: number) { + return new TimeDuration(amount, TimeUnit.Millisecond); } public isZero() { diff --git a/Apps/Client/src/pages/QuizPage.tsx b/Apps/Client/src/pages/QuizPage.tsx index cacefcf..973d5fb 100644 --- a/Apps/Client/src/pages/QuizPage.tsx +++ b/Apps/Client/src/pages/QuizPage.tsx @@ -4,15 +4,15 @@ import QuestionForm from '../components/forms/QuestionForm'; import { REFRESH_STATUS_INTERVAL } from '../config'; import AdminQuizForm from '../components/forms/AdminQuizForm'; import { useTranslation } from 'react-i18next'; -import { AspectRatio, Language, QuestionType } from '../constants'; +import { AspectRatio, Language, NO_QUESTION_INDEX, QuestionType } from '../constants'; import Page from './Page'; -import useServerCountdownTimer from '../hooks/useServerCountdownTimer'; import useQuiz from '../hooks/useQuiz'; import useUser from '../hooks/useUser'; import useApp from '../hooks/useApp'; import useOverlay from '../hooks/useOverlay'; import { OverlayName } from '../reducers/OverlaysReducer'; import useQuestion from '../hooks/useQuestion'; +import useServerTimer from '../hooks/useServerTimer'; const QuizPage: React.FC = () => { const { t, i18n } = useTranslation(); @@ -28,10 +28,13 @@ const QuizPage: React.FC = () => { const answerOverlay = useOverlay(OverlayName.Answer); const lobbyOverlay = useOverlay(OverlayName.Lobby); - const timer = useServerCountdownTimer(); + const timer = useServerTimer(); const [choice, setChoice] = useState(''); + const hasTimerQuestionIndex = quiz.status?.timer?.questionIndex !== undefined; + const timerQuestionIndex = hasTimerQuestionIndex ? quiz.status!.timer!.questionIndex : NO_QUESTION_INDEX; + const isReady = quiz.id !== null && quiz.questions !== null && quiz.status !== null && question !== null && question.data !== null; @@ -77,12 +80,7 @@ const QuizPage: React.FC = () => { return; } - quiz.refreshStatusPlayersAndScores() - .then(() => { - if (timer.isEnabled) { - timer.restart(); - } - }); + quiz.refreshStatusPlayersAndScores(); }, [app.questionIndex]); @@ -108,10 +106,10 @@ const QuizPage: React.FC = () => { if (choice !== question.answer.chosen.value) { setChoice(question.answer.chosen.value); - } - if (!answerOverlay.isOpen) { - answerOverlay.open(); + if (!answerOverlay.isOpen) { + answerOverlay.open(); + } } }, [question.answer.chosen]); @@ -128,22 +126,39 @@ const QuizPage: React.FC = () => { + // FIXME: what if every user is answering at their own pace? Then server timer does not make sense! // Start timer if enabled - const shouldStartTimer = timer.isEnabled && !timer.isRunning && quiz.isStarted; + const shouldStartTimer = quiz.isStarted && timer.isEnabled && !timer.isRunning && !timer.isDone; + useEffect(() => { if (shouldStartTimer) { - timer.start(); + timer.start(); } }, [shouldStartTimer]); + // Restart timer when moving to next question + const shouldRestartTimer = quiz.isStarted && timer.isEnabled && timerQuestionIndex !== NO_QUESTION_INDEX; + + useEffect(() => { + if (shouldRestartTimer) { + timer.restart(); + } + }, [timerQuestionIndex]); + + + + // FIXME: close answer overlay when timer is up for current question! // Show answer once timer has expired - const shouldShowAnswer = timer.isEnabled && quiz.isStarted && timer.isDone; + const shouldShowAnswer = timer.isEnabled && timer.isDone && quiz.isStarted; useEffect(() => { - if (shouldShowAnswer) { + if (shouldShowAnswer && !answerOverlay.isOpen) { answerOverlay.open(); } + if (!shouldShowAnswer && answerOverlay.isOpen) { + answerOverlay.close(); + } }, [shouldShowAnswer]); @@ -179,7 +194,7 @@ const QuizPage: React.FC = () => { )} {quiz.isStarted && ( { throw new InvalidQuestionIndexError(); } + // We can finally move on to next question + await quiz.setQuestionIndex(questionIndex); + logger.info(`Question ${questionIndex + 1}/${questionCount} of quiz '${quiz.getId()}' has been started by admin '${username}'.`); + // Restart timer for new question if (quiz.isTimed()) { await quiz.restartTimer(); logger.debug(`Restarted timer of quiz (ID = ${quiz.getId()}).`); } - // We can finally move on to next question - await quiz.setQuestionIndex(questionIndex); - logger.info(`Question ${questionIndex + 1}/${questionCount} of quiz '${quiz.getId()}' has been started by admin '${username}'.`); - return res.json(successResponse()); } catch (err: any) { diff --git a/Apps/Server/src/models/Quiz.ts b/Apps/Server/src/models/Quiz.ts index 2ee8845..d90d166 100644 --- a/Apps/Server/src/models/Quiz.ts +++ b/Apps/Server/src/models/Quiz.ts @@ -59,6 +59,7 @@ class Quiz { ...this.status, ...(this.status.timer ? { timer: { + questionIndex: this.status.timer.questionIndex, startedAt: this.status.timer.startedAt!.toUTCString(), duration: this.status.timer.duration, }, @@ -76,6 +77,7 @@ class Quiz { ...quiz.status, ...(quiz.status.timer ? { timer: { + questionIndex: quiz.status.timer.questionIndex, startedAt: new Date(quiz.status.timer.startedAt), duration: quiz.status.timer.duration, }, @@ -162,6 +164,7 @@ class Quiz { // Create a timer if (isTimed) { this.status.timer = { + questionIndex: this.getQuestionIndex(), startedAt: new Date(), duration: { amount: TIMER_DURATION.getAmount(), @@ -228,6 +231,8 @@ class Quiz { throw new Error('MISSING_TIMER'); } + // Assign question index to timer so client app knows when a new timer has been created + this.status.timer!.questionIndex = this.getQuestionIndex(); this.status.timer!.startedAt = new Date(); await this.save(); diff --git a/Apps/Server/src/types/DataTypes.ts b/Apps/Server/src/types/DataTypes.ts index b9bae39..b5a4d73 100644 --- a/Apps/Server/src/types/DataTypes.ts +++ b/Apps/Server/src/types/DataTypes.ts @@ -31,6 +31,7 @@ export type AnswerData = { }; export type TimerData = { + questionIndex: number, startedAt: Date, duration: { amount: number,