From aa3ae79b1a565a69a3e9289443186ca5a701b2c8 Mon Sep 17 00:00:00 2001 From: ByeongChan Choi <77400298+chan-byeong@users.noreply.github.com> Date: Tue, 3 Dec 2024 16:39:23 +0900 Subject: [PATCH] =?UTF-8?q?[FE]=20-=20=EB=A7=88=EC=8A=A4=ED=84=B0,=20?= =?UTF-8?q?=EC=B0=B8=EA=B0=80=EC=9E=90=20=ED=80=B4=EC=A6=88=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20(#157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: pinCode Check API 구현 * feat: Nickname 페이지 커스텀 훅 구현 * feat: 메인 페이지에서 핀코드 유효성(방 정원 초과 여부) 검사 * fix: session 이벤트 수정 * fix: 커스텀 훅 적용 및 lazy 페이지로 변경 * fix: import 경로 변경 * fix: Prevent Router 적용 * refactor: quiz session 로직 변경 * refactor: quiz-session, master-session 코드 리팩토링 * feat: usePersistState 커스텀 훅 생성 * feat: clearLocalStorage 유틸 함수 구현 * refactor: participant quiz-session 구조 개선 * feat: 로컬 스토리지를 활용한 훅 usePersistState 추가하여 새로고침 시 마스터 통계값 저장 * fix: 사용하지 않는 변수 및 함수 삭제 * feat: 남은 시간 로컬 스토리지 추가 및 그래프 색상, 텍스트 변경 * feat: 서버 코드 변경 * feat: 퀴즈 시작 시 clearLocalStoarge 추가 --------- Co-authored-by: dooohun --- .../pages/quiz-master-session/index.lazy.tsx | 55 +++++++ .../src/pages/quiz-master-session/index.tsx | 141 +----------------- .../quiz-master-session/ui/AnswerChart.tsx | 59 ++++---- .../ui/QuizMasterHeader.tsx | 68 +++++++++ .../quiz-master-session/ui/Statistics.tsx | 89 +++++++++++ .../src/pages/quiz-session/index.lazy.tsx | 37 +++++ .../client/src/pages/quiz-session/index.tsx | 96 +----------- .../model/hooks/useQuizSession.ts | 22 +++ .../model/hooks/useShowRanking.ts | 18 +++ .../src/pages/quiz-session/ui/QuizBox.tsx | 42 +++--- .../src/pages/quiz-session/ui/QuizEnd.tsx | 54 +++++-- .../src/pages/quiz-session/ui/QuizHeader.tsx | 37 ++++- .../client/src/pages/quiz-wait/index.lazy.tsx | 8 +- .../src/shared/hooks/usePersistState.ts | 25 ++++ .../src/shared/utils/clearLocalStorage.ts | 5 + .../src/shared/utils/emitEventWithDelay.ts | 10 ++ .../src/module/game/games/game.gateway.ts | 125 +++++++++++----- 17 files changed, 551 insertions(+), 340 deletions(-) create mode 100644 packages/client/src/pages/quiz-master-session/index.lazy.tsx create mode 100644 packages/client/src/pages/quiz-master-session/ui/QuizMasterHeader.tsx create mode 100644 packages/client/src/pages/quiz-master-session/ui/Statistics.tsx create mode 100644 packages/client/src/pages/quiz-session/index.lazy.tsx create mode 100644 packages/client/src/pages/quiz-session/model/hooks/useQuizSession.ts create mode 100644 packages/client/src/pages/quiz-session/model/hooks/useShowRanking.ts create mode 100644 packages/client/src/shared/hooks/usePersistState.ts create mode 100644 packages/client/src/shared/utils/clearLocalStorage.ts create mode 100644 packages/client/src/shared/utils/emitEventWithDelay.ts diff --git a/packages/client/src/pages/quiz-master-session/index.lazy.tsx b/packages/client/src/pages/quiz-master-session/index.lazy.tsx new file mode 100644 index 00000000..0d5cbc67 --- /dev/null +++ b/packages/client/src/pages/quiz-master-session/index.lazy.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { getCookie } from '@/shared/utils/cookie'; +import { getQuizSocket } from '@/shared/utils/socket'; + +import { useQuizSession } from '../quiz-session/model/hooks/useQuizSession'; +import QuizMasterHeader from './ui/QuizMasterHeader'; +import Statistics from './ui/Statistics'; +import { clearLocalStorage } from '@/shared/utils/clearLocalStorage'; + +const LOCAL_STORAGE_KEYS = ['masterStatistics', 'reactionStats', 'history', 'remainingTime']; + +export default function QuizMasterSessionLazyPage() { + const { pinCode, id } = useParams(); + const navigate = useNavigate(); + const socket = getQuizSocket(); + const [initializeStates, setInitializeStates] = useState(false); + + const { data: quiz, refetch } = useQuizSession({ socket, pinCode: pinCode as string }); + + const handleNextQuiz = () => { + if (quiz.isLast) { + socket.emit('end quiz', { pinCode, sid: getCookie('sid') }); + clearLocalStorage(LOCAL_STORAGE_KEYS); + navigate(`/quiz/session/${pinCode}/end`); + return; + } + + socket.emitWithAck('start quiz', { pinCode, sid: getCookie('sid') }).then(() => { + clearLocalStorage(LOCAL_STORAGE_KEYS); + navigate(`/quiz/session/host/${pinCode}/${parseInt(id as string) + 1}`); + refetch(); + setInitializeStates(true); + }); + }; + + return ( +
+ + +
+ ); +} diff --git a/packages/client/src/pages/quiz-master-session/index.tsx b/packages/client/src/pages/quiz-master-session/index.tsx index 157ce51b..3e3f7cba 100644 --- a/packages/client/src/pages/quiz-master-session/index.tsx +++ b/packages/client/src/pages/quiz-master-session/index.tsx @@ -1,141 +1,10 @@ -import { useNavigate, useParams } from 'react-router-dom'; -import { useEffect, useState } from 'react'; - -import { getCookie } from '@/shared/utils/cookie'; -import AnswerGraph from '@/pages/quiz-master-session/ui/AnswerChart'; -import RecentSubmittedAnswers from './ui/RecentSubmittedAnswers'; -import { getQuizSocket } from '@/shared/utils/socket'; -import StatisticsGroup from './ui/StatisticsGroup'; -import { QuizData } from '@youquiz/shared/interfaces/utils/quizdata.interface'; -import { - TimerTickResponse, - MasterStatisticsResponse, - ShowQuizResponse, -} from '@youquiz/shared/interfaces/response'; -import { - INITIAL_QUIZ_DATA, - INITIAL_TICK, - INITIAL_MASTER_STATISTICS, - INITIAL_EMOJI, -} from '@/shared/constants/initialState'; -import EmojiChart from './ui/EmojiChart'; - -interface HistoryItem { - user: string; - submitTime: number; - elapsedTime: number; - displayTime: string; -} +import AsyncBoundary from '@/shared/boundary/AsyncBoundary'; +import QuizMasterSessionLazyPage from './index.lazy'; export default function QuizMasterSession() { - const { pinCode } = useParams(); - const navigate = useNavigate(); - const socket = getQuizSocket(); - const [masterStatistics, setMasterStatistics] = - useState(INITIAL_MASTER_STATISTICS); - const [quizData, setQuizData] = useState(INITIAL_QUIZ_DATA); - const [tick, setTick] = useState(INITIAL_TICK); - const [quizIndex, setQuizIndex] = useState(0); - const [isLastQuiz, setIsLastQuiz] = useState(false); - const [reactionStats, setReactionStats] = useState(INITIAL_EMOJI); - const [history, setHistory] = useState([]); - - const initQuizData = () => { - setQuizData(INITIAL_QUIZ_DATA); - setMasterStatistics(INITIAL_MASTER_STATISTICS); - setTick(INITIAL_TICK); - setHistory([]); - setReactionStats(INITIAL_EMOJI); - }; - - const handleNextQuiz = () => { - if (isLastQuiz) { - socket.emit('end quiz', { pinCode, sid: getCookie('sid') }); - navigate(`/quiz/session/${pinCode}/end`); - return; - } - if (Math.floor(tick.remainingTime / 1000) === 0) { - initQuizData(); - setQuizIndex((prev) => prev + 1); - socket.emit('show quiz', { pinCode }); - } - }; - - useEffect(() => { - socket.emit('show quiz', { pinCode }); - - const handleShowQuiz = (response: ShowQuizResponse) => { - const { currentQuizData, isLast } = response; - setQuizData(currentQuizData); - setIsLastQuiz(isLast); - }; - const handleMasterStatistics = (response: MasterStatisticsResponse) => { - setMasterStatistics(response); - }; - const handleTimerTick = (response: TimerTickResponse) => { - setTick(response); - }; - - const handleReactionUpdate = (response: { easy: number; hard: number }) => { - setReactionStats(response); - }; - - socket.on('show quiz', handleShowQuiz); - socket.on('master statistics', handleMasterStatistics); - socket.on('timer tick', handleTimerTick); - socket.on('emoji', handleReactionUpdate); - - return () => { - socket.off('show quiz', handleShowQuiz); - socket.off('master statistics', handleMasterStatistics); - socket.off('timer tick', handleTimerTick); - }; - }, []); return ( -
-
-
-
-

실시간 통계

-

- Q{quizIndex + 1}. {quizData.content} -

-
-
-

- 제한 시간{' '} - {Math.floor(tick.remainingTime / 1000) === 0 - ? '종료' - : Math.floor(tick.remainingTime / 1000)} -

-
- -
-
-
-
- -
- -
- - -
-
-
+ + + ); } diff --git a/packages/client/src/pages/quiz-master-session/ui/AnswerChart.tsx b/packages/client/src/pages/quiz-master-session/ui/AnswerChart.tsx index 341b236d..724b6140 100644 --- a/packages/client/src/pages/quiz-master-session/ui/AnswerChart.tsx +++ b/packages/client/src/pages/quiz-master-session/ui/AnswerChart.tsx @@ -7,16 +7,15 @@ import { Tooltip, Bar, Legend, + Cell, } from 'recharts'; -import { useEffect, useState } from 'react'; import { MasterStatisticsResponse } from '@youquiz/shared/interfaces/response'; import { QuizData } from '@youquiz/shared/interfaces/utils/quizdata.interface'; -import LoadingSpinner from '@/shared/assets/icons/loading-alt-loop.svg?react'; interface AnswerStatProps { answerStats: MasterStatisticsResponse['choiceStatus']; - quizData: QuizData | null; + quizData: QuizData; participantCount: number; } @@ -30,35 +29,37 @@ const calculateTickCount = (maxValue: number): number => { }; export default function AnswerGraph({ answerStats, quizData, participantCount }: AnswerStatProps) { - const [answerStatsArray, setAnswerStatsArray] = useState<{ answer: string; count: number }[]>([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (quizData?.choices) { - const formattedData = Object.entries(answerStats).map(([key, count]) => ({ - answer: - `${parseInt(key) + 1}번: ${quizData.choices[parseInt(key)].content}` || `답변 ${key}`, - count, - })); - setAnswerStatsArray(formattedData); - setLoading(false); - } - }, [quizData, answerStats]); - + const answerStatsArray = quizData.choices.map((choice, index) => ({ + answer: `${index + 1}번: ${choice.content} ${choice.isCorrect ? '(정답)' : ''}`, + count: answerStats[index] || 0, + isCorrect: choice.isCorrect, + })); const tickCount = calculateTickCount(participantCount); - if (loading) { - return ( -
- -
- ); - } return ( - + { + const answerData = answerStatsArray.find((item) => item.answer === payload.value); + const isCorrect = answerData?.isCorrect; + return ( + + {payload.value} + + ); + }} + /> [`${value}명`, '참여자 수']} /> '참여자 수'} /> - + + {answerStatsArray.map((entry, index) => ( + + ))} + ); diff --git a/packages/client/src/pages/quiz-master-session/ui/QuizMasterHeader.tsx b/packages/client/src/pages/quiz-master-session/ui/QuizMasterHeader.tsx new file mode 100644 index 00000000..20dfe1d8 --- /dev/null +++ b/packages/client/src/pages/quiz-master-session/ui/QuizMasterHeader.tsx @@ -0,0 +1,68 @@ +import { usePersistState } from '@/shared/hooks/usePersistState'; +import { useEffect } from 'react'; + +import { Socket } from 'socket.io-client'; + +interface QuizMasterHeaderProps { + quizData: QuizData; + startTime: number; + timeLimit: number; + handleNextQuiz: () => void; + pinCode: string; + socket: Socket; +} + +export default function QuizMasterHeader({ + quizData, + startTime, + timeLimit, + handleNextQuiz, + pinCode, + socket, +}: QuizMasterHeaderProps) { + const [remainingTime, setRemainingTime] = usePersistState('remainingTime', timeLimit); + + useEffect(() => { + const intervalId = setInterval(() => { + const timeLeft = timeLimit - Math.floor((Date.now() - startTime) / 1000); + setRemainingTime(timeLeft); + }, 1000); + + return () => { + clearInterval(intervalId); + }; + }, [startTime]); + + useEffect(() => { + if (remainingTime === 0) { + socket.emit('time end', { pinCode: pinCode }); + } + }, [remainingTime]); + + return ( +
+
+
+

실시간 통계

+

+ Q{quizData.position + 1}. {quizData.content} +

+
+
+

+ 제한 시간 {remainingTime <= 0 ? '종료' : remainingTime} +

+
+ +
+
+
+
+ ); +} diff --git a/packages/client/src/pages/quiz-master-session/ui/Statistics.tsx b/packages/client/src/pages/quiz-master-session/ui/Statistics.tsx new file mode 100644 index 00000000..016db9ed --- /dev/null +++ b/packages/client/src/pages/quiz-master-session/ui/Statistics.tsx @@ -0,0 +1,89 @@ +import { INITIAL_MASTER_STATISTICS } from '@/shared/constants/initialState'; +import { INITIAL_EMOJI } from '@/shared/constants/initialState'; +import AnswerGraph from './AnswerChart'; +import RecentSubmittedAnswers from './RecentSubmittedAnswers'; +import StatisticsGroup from './StatisticsGroup'; +import EmojiChart from './EmojiChart'; +import { useEffect } from 'react'; +import { MasterStatisticsResponse } from '@youquiz/shared/interfaces/response/master-statistics.response.interface'; +import { getQuizSocket } from '@/shared/utils/socket'; +import { usePersistState } from '@/shared/hooks/usePersistState'; + +interface StatisticsProps { + quizData: QuizData; + initializeStates: boolean; + setInitializeStates: React.Dispatch>; +} + +interface HistoryItem { + user: string; + submitTime: number; + elapsedTime: number; + displayTime: string; +} + +export default function Statistics({ + quizData, + initializeStates, + setInitializeStates, +}: StatisticsProps) { + const socket = getQuizSocket(); + const [masterStatistics, setMasterStatistics] = usePersistState( + 'masterStatistics', + INITIAL_MASTER_STATISTICS, + ); + const [reactionStats, setReactionStats] = usePersistState('reactionStats', INITIAL_EMOJI); + const [history, setHistory] = usePersistState('history', []); + + const initializeStatistics = () => { + setMasterStatistics(INITIAL_MASTER_STATISTICS); + setReactionStats(INITIAL_EMOJI); + setHistory([]); + }; + + useEffect(() => { + const handleMasterStatistics = (response: MasterStatisticsResponse) => { + setMasterStatistics(response); + }; + + const handleReactionUpdate = (response: { easy: number; hard: number }) => { + setReactionStats(response); + }; + + socket.on('master statistics', handleMasterStatistics); + socket.on('emoji', handleReactionUpdate); + + return () => { + socket.off('master statistics', handleMasterStatistics); + socket.off('emoji', handleReactionUpdate); + }; + }, []); + + useEffect(() => { + if (initializeStates) { + initializeStatistics(); + setInitializeStates(false); + } + }, [initializeStates]); + + return ( + <> + +
+ +
+ + +
+
+ + ); +} diff --git a/packages/client/src/pages/quiz-session/index.lazy.tsx b/packages/client/src/pages/quiz-session/index.lazy.tsx new file mode 100644 index 00000000..463ec74d --- /dev/null +++ b/packages/client/src/pages/quiz-session/index.lazy.tsx @@ -0,0 +1,37 @@ +import { useParams } from 'react-router-dom'; + +import QuizBox from './ui/QuizBox'; +import QuizEnd from './ui/QuizEnd'; +import QuizHeader from './ui/QuizHeader'; +import { useQuizSession } from './model/hooks/useQuizSession'; +import { usePersistState } from '@/shared/hooks/usePersistState'; +import { getQuizSocket } from '@/shared/utils/socket'; + +export default function QuizSessionLazyPage() { + const socket = getQuizSocket(); + const { pinCode } = useParams(); + const [isQuizEnd, setIsQuizEnd] = usePersistState('isQuizEnd', false); + const { data: quiz, refetch } = useQuizSession({ socket, pinCode: pinCode as string }); + + return ( + <> + {!isQuizEnd && ( +
+ + +
+ )} + {isQuizEnd && ( + + )} + + ); +} diff --git a/packages/client/src/pages/quiz-session/index.tsx b/packages/client/src/pages/quiz-session/index.tsx index 3b30980c..35558e51 100644 --- a/packages/client/src/pages/quiz-session/index.tsx +++ b/packages/client/src/pages/quiz-session/index.tsx @@ -1,96 +1,10 @@ -import { useState, useEffect } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; - -import { getQuizSocket } from '@/shared/utils/socket'; -import QuizBox from './ui/QuizBox'; -import QuizEnd from './ui/QuizEnd'; -import QuizHeader from './ui/QuizHeader'; -import QuizLoading from './ui/QuizLoading'; -// import { toastController } from '@/features/toast/model/toastController'; -import { QuizData } from '@youquiz/shared/interfaces/utils/quizdata.interface'; -import { ShowQuizResponse, TimerTickResponse } from '@youquiz/shared/interfaces/response'; -import { INITIAL_QUIZ_DATA, INITIAL_TICK } from '@/shared/constants/initialState'; +import AsyncBoundary from '@/shared/boundary/AsyncBoundary'; +import QuizSessionLazyPage from './index.lazy'; export default function QuizSession() { - const socket = getQuizSocket(); - // const toast = toastController(); - const navigate = useNavigate(); - const { pinCode } = useParams(); - const [isLoading, setIsLoading] = useState(true); - const [isQuizEnd, setIsQuizEnd] = useState(false); - const [tick, setTick] = useState(INITIAL_TICK); - const [quiz, setQuiz] = useState(INITIAL_QUIZ_DATA); - - useEffect(() => { - const quizPromise = new Promise((resolve, reject) => { - const handleShowQuiz = (response: ShowQuizResponse) => { - const { currentQuizData } = response; - setQuiz(currentQuizData); - setIsLoading(true); - setIsQuizEnd(false); - resolve(currentQuizData); - }; - - socket.on('show quiz', handleShowQuiz); - - const timer = setTimeout(() => { - reject(new Error('Timeout')); - }, 2000); - - return () => { - socket.off('show quiz', handleShowQuiz); - clearTimeout(timer); - }; - }); - - const timerPromise = new Promise((resolve) => { - const timer = setTimeout(resolve, 2000); - return () => { - clearTimeout(timer); - }; - }); - - Promise.all([quizPromise, timerPromise]) - .then(() => { - setIsLoading(false); - }) - .catch(() => { - // toast.error('문제 로딩에 실패했습니다.'); - setIsLoading(false); - }); - - const handleTick = (response: TimerTickResponse) => { - setTick(response); - }; - - const handleTimeEnd = () => { - setIsQuizEnd(true); - }; - - const handleQuizEnd = () => { - navigate(`/quiz/session/${pinCode}/end`); - }; - - socket.on('end quiz', handleQuizEnd); - socket.on('timer tick', handleTick); - socket.on('time end', handleTimeEnd); - - return () => { - socket.off('timer tick', handleTick); - socket.off('time end', handleTimeEnd); - }; - }, [quiz]); - return ( - <> - {isLoading && !isQuizEnd && } - {!isLoading && !isQuizEnd && ( -
- - -
- )} - {isQuizEnd && } - + + + ); } diff --git a/packages/client/src/pages/quiz-session/model/hooks/useQuizSession.ts b/packages/client/src/pages/quiz-session/model/hooks/useQuizSession.ts new file mode 100644 index 00000000..0e80a3fe --- /dev/null +++ b/packages/client/src/pages/quiz-session/model/hooks/useQuizSession.ts @@ -0,0 +1,22 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { emitEventWithAck } from '@/shared/utils/emitEventWithAck'; +import { Socket } from 'socket.io-client'; + +interface UseQuizSessionProps { + socket: Socket; + pinCode: string; +} + +interface ShowQuizResponse { + quizMaxNum: number; + currentQuizData: QuizData; + isLast: boolean; + startTime: number; +} + +export const useQuizSession = ({ socket, pinCode }: UseQuizSessionProps) => { + return useSuspenseQuery({ + queryKey: ['show quiz', pinCode], + queryFn: () => emitEventWithAck(socket, 'show quiz', { pinCode }), + }); +}; diff --git a/packages/client/src/pages/quiz-session/model/hooks/useShowRanking.ts b/packages/client/src/pages/quiz-session/model/hooks/useShowRanking.ts new file mode 100644 index 00000000..7bf21ac5 --- /dev/null +++ b/packages/client/src/pages/quiz-session/model/hooks/useShowRanking.ts @@ -0,0 +1,18 @@ +import { getCookie } from '@/shared/utils/cookie'; +import { emitEventWithAck } from '@/shared/utils/emitEventWithAck'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { Socket } from 'socket.io-client'; + +interface UseShowRankingProps { + socket: Socket; + pinCode: string; +} + +export const useShowRanking = ({ socket, pinCode }: UseShowRankingProps) => { + return useSuspenseQuery({ + queryKey: ['showRanking', pinCode], + queryFn: () => { + return emitEventWithAck(socket, 'show ranking', { pinCode, sid: getCookie('sid') }); + }, + }); +}; diff --git a/packages/client/src/pages/quiz-session/ui/QuizBox.tsx b/packages/client/src/pages/quiz-session/ui/QuizBox.tsx index a181ee38..18509a2d 100644 --- a/packages/client/src/pages/quiz-session/ui/QuizBox.tsx +++ b/packages/client/src/pages/quiz-session/ui/QuizBox.tsx @@ -5,25 +5,24 @@ import { getCookie } from '@/shared/utils/cookie'; import { useParams } from 'react-router-dom'; import AfterQuizSubmit from './AfterQuizSubmit'; import QuizBackground from './QuizBackground'; -import { - TimerTickResponse, - ParticipantStatisticsResponse, -} from '@youquiz/shared/interfaces/response'; +import { ParticipantStatisticsResponse } from '@youquiz/shared/interfaces/response'; import { INITIAL_PARTICIPANT_STATISTICS, INITIAL_EMOJI } from '@/shared/constants/initialState'; +import { usePersistState } from '@/shared/hooks/usePersistState'; interface QuizBoxProps { quiz: QuizData; - tick: TimerTickResponse; + startTime: number; } -export default function QuizBox({ quiz, tick }: QuizBoxProps) { +export default function QuizBox({ quiz, startTime }: QuizBoxProps) { const { pinCode } = useParams(); const [selectedAnswer, setSelectedAnswer] = useState([]); - const [hasSubmitted, setHasSubmitted] = useState(false); - const [reactionStats, setReactionStats] = useState(INITIAL_EMOJI); - const [participantStatistics, setParticipantStatistics] = useState( + const [hasSubmitted, setHasSubmitted] = usePersistState('hasSubmitted', false); + const [reactionStats, setReactionStats] = usePersistState('reactionStats', INITIAL_EMOJI); + const [participantStatistics, setParticipantStatistics] = usePersistState( + 'participantStatistics', INITIAL_PARTICIPANT_STATISTICS, ); - const [submitOrder, setSubmitOrder] = useState(0); + const [submitOrder, setSubmitOrder] = usePersistState('submitOrder', 0); const easyButtonRef = useRef(null); const hardButtonRef = useRef(null); @@ -41,19 +40,16 @@ export default function QuizBox({ quiz, tick }: QuizBoxProps) { }); }; - const handleSubmit = () => { - socket.emit( - 'submit answer', - { - selectedAnswer: selectedAnswer, - sid: getCookie('sid'), - pinCode: pinCode, - submitTime: tick.elapsedTime, - }, - (response: any) => { - setSubmitOrder(response.submitOrder); - }, - ); + const handleSubmit = async () => { + const { submitOrder } = await socket.emitWithAck('submit answer', { + selectedAnswer: selectedAnswer, + sid: getCookie('sid'), + pinCode: pinCode, + submitTime: Date.now() - startTime, + }); + + setSubmitOrder(submitOrder); + setHasSubmitted(true); }; diff --git a/packages/client/src/pages/quiz-session/ui/QuizEnd.tsx b/packages/client/src/pages/quiz-session/ui/QuizEnd.tsx index 11355c5d..e667c8fe 100644 --- a/packages/client/src/pages/quiz-session/ui/QuizEnd.tsx +++ b/packages/client/src/pages/quiz-session/ui/QuizEnd.tsx @@ -1,7 +1,14 @@ -import { useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + import { getQuizSocket } from '@/shared/utils/socket'; -import { getCookie } from '@/shared/utils/cookie'; +import { useShowRanking } from '../model/hooks/useShowRanking'; +import { clearLocalStorage } from '@/shared/utils/clearLocalStorage'; +interface QuizEndProps { + quizOrder: number; + refetch: () => void; + setQuizEnd: React.Dispatch>; +} const Nickname = ({ nickname }: { nickname: string }) => { return ( @@ -11,16 +18,43 @@ const Nickname = ({ nickname }: { nickname: string }) => { ); }; -export default function QuizEnd() { - const socket = getQuizSocket(); - const { pinCode } = useParams(); +const LOCAL_STORAGE_KEYS = [ + 'isQuizEnd', + 'reactionStats', + 'participantStatistics', + 'hasSubmitted', + 'submitOrder', + 'remianingTime', +]; - const [ranking, setRanking] = useState([]); +export default function QuizEnd({ refetch, setQuizEnd }: QuizEndProps) { + const socket = getQuizSocket(); + const navigate = useNavigate(); + const { pinCode, id } = useParams(); + const { data: ranking } = useShowRanking({ socket, pinCode: pinCode as string }); + console.log(ranking); + // TODO: localStorage 삭제하기 useEffect(() => { - socket.emit('show ranking', { pinCode, sid: getCookie('sid') }, (response: any) => { - setRanking(response); - }); + const handleStartQuiz = () => { + clearLocalStorage(LOCAL_STORAGE_KEYS); + navigate(`/quiz/session/${pinCode}/${parseInt(id as string) + 1}`); + setQuizEnd(false); + refetch(); + }; + + const handleEndQuiz = () => { + clearLocalStorage(LOCAL_STORAGE_KEYS); + navigate(`/quiz/session/${pinCode}/end`); + }; + + socket.on('start quiz', handleStartQuiz); + socket.on('end quiz', handleEndQuiz); + + return () => { + socket.off('start quiz', handleStartQuiz); + socket.off('end quiz', handleEndQuiz); + }; }, []); return ( diff --git a/packages/client/src/pages/quiz-session/ui/QuizHeader.tsx b/packages/client/src/pages/quiz-session/ui/QuizHeader.tsx index a83ec1fe..8c4f3acb 100644 --- a/packages/client/src/pages/quiz-session/ui/QuizHeader.tsx +++ b/packages/client/src/pages/quiz-session/ui/QuizHeader.tsx @@ -1,24 +1,45 @@ import { useEffect, useState } from 'react'; import { getQuizSocket } from '@/shared/utils/socket'; -import { TimerTickResponse } from '@youquiz/shared/interfaces/response'; +import { usePersistState } from '@/shared/hooks/usePersistState'; interface QuizHeaderProps { - tick: TimerTickResponse; + startTime: number; + timeLimit: number; + setQuizEnd: React.Dispatch>; } -export default function QuizHeader({ tick }: QuizHeaderProps) { +export default function QuizHeader({ startTime, timeLimit, setQuizEnd }: QuizHeaderProps) { const socket = getQuizSocket(); - const [submitStatus, setSubmitStatus] = useState<{ count: number; total: number }>({ + const [submitStatus, setSubmitStatus] = useState({ count: 0, total: 0, }); + const [remainingTime, setRemainingTime] = usePersistState('ramainingTime', timeLimit); - const handleSubmitStatus = (status: { count: number; total: number }) => { - setSubmitStatus(status); - }; + useEffect(() => { + const intervalId = setInterval(() => { + const timeLeft = timeLimit - Math.floor((Date.now() - startTime) / 1000); + setRemainingTime(timeLeft); + }, 1000); + + return () => { + clearInterval(intervalId); + }; + }, [startTime, timeLimit]); useEffect(() => { + if (remainingTime === 0) { + setQuizEnd(true); + } + }, [remainingTime]); + + useEffect(() => { + const handleSubmitStatus = (status: any) => { + console.log('submitStatus', status); + setSubmitStatus(status); + }; + socket.on('submit status', handleSubmitStatus); return () => { @@ -32,7 +53,7 @@ export default function QuizHeader({ tick }: QuizHeaderProps) {
{submitStatus.count} / {submitStatus.total}명 제출
-
{Math.floor(tick.remainingTime / 1000)}초 남음
+
{remainingTime}초 남음
); diff --git a/packages/client/src/pages/quiz-wait/index.lazy.tsx b/packages/client/src/pages/quiz-wait/index.lazy.tsx index 1bc1c719..e1e55928 100644 --- a/packages/client/src/pages/quiz-wait/index.lazy.tsx +++ b/packages/client/src/pages/quiz-wait/index.lazy.tsx @@ -41,8 +41,7 @@ export default function QuizWaitLazyPage() { setUserType(response.type); }; - socket.on('start quiz', (response) => { - console.log('start quiz', response); + socket.on('start quiz', () => { navigate(`/quiz/session/${pinCode}/1`); }); @@ -66,8 +65,9 @@ export default function QuizWaitLazyPage() { }; const handleQuizStart = () => { - socket.emit('start quiz', { sid: getCookie('sid'), pinCode }); - navigate(`/quiz/session/host/${pinCode}/1`); + socket.emitWithAck('start quiz', { sid: getCookie('sid'), pinCode }).then(() => { + navigate(`/quiz/session/host/${pinCode}/0`); + }); }; return ( diff --git a/packages/client/src/shared/hooks/usePersistState.ts b/packages/client/src/shared/hooks/usePersistState.ts new file mode 100644 index 00000000..dd8f5b0d --- /dev/null +++ b/packages/client/src/shared/hooks/usePersistState.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react'; + +export function usePersistState( + key: string, + initialState: T, +): [T, React.Dispatch>] { + const [state, setState] = useState(() => { + const persistedState = localStorage.getItem(key); + return persistedState ? JSON.parse(persistedState) : initialState; + }); + + useEffect(() => { + const handleBeforeUnload = () => { + localStorage.setItem(key, JSON.stringify(state)); + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [key, state]); + + return [state, setState]; +} diff --git a/packages/client/src/shared/utils/clearLocalStorage.ts b/packages/client/src/shared/utils/clearLocalStorage.ts new file mode 100644 index 00000000..1d7ad4f7 --- /dev/null +++ b/packages/client/src/shared/utils/clearLocalStorage.ts @@ -0,0 +1,5 @@ +export const clearLocalStorage = (keys: string[]) => { + keys.forEach((key) => { + localStorage.removeItem(key); + }); +}; diff --git a/packages/client/src/shared/utils/emitEventWithDelay.ts b/packages/client/src/shared/utils/emitEventWithDelay.ts new file mode 100644 index 00000000..5cc97750 --- /dev/null +++ b/packages/client/src/shared/utils/emitEventWithDelay.ts @@ -0,0 +1,10 @@ +import { Socket } from 'socket.io-client'; + +export const emitEventWithDelay = (socket: Socket, event: string, data: any, delay: number) => { + return new Promise((resolve) => { + socket.emit(event, data); + setTimeout(() => { + resolve(true); + }, delay); + }); +}; diff --git a/packages/server/src/module/game/games/game.gateway.ts b/packages/server/src/module/game/games/game.gateway.ts index 45e672d9..42517f71 100644 --- a/packages/server/src/module/game/games/game.gateway.ts +++ b/packages/server/src/module/game/games/game.gateway.ts @@ -72,6 +72,16 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect { console.log(`Client disconnected: ${client.id}`); // TODO: connection 상태 변경 필요 // 마스터 참여자 여부에 따라서 disconnection 관리 로직 다를듯 + // const timeoutKey = `timeout:${client.id}`; + // await this.redisService.set(timeoutKey, 'delete', 'EX', 30); + + // // 30초 후 데이터 삭제 로직 + // setTimeout(async () => { + // const timeoutExists = await this.redisService.get(timeoutKey); + // if (timeoutExists) { + // await this.redisService.del(client.id); + // } + // }, 30000); } @SubscribeMessage('master entry') @@ -95,7 +105,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect { const quizMaxNum = quizData.length - 1; const gameStatus = GAMESTATUS_TYPES.WAITING; - const gameInfo = { classId, gameStatus, currentOrder: 0, quizMaxNum, participantList: [] }; + const gameInfo = { classId, gameStatus, currentOrder: -1, quizMaxNum, participantList: [] }; await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); @@ -157,25 +167,40 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect { return { myPosition, participantList }; } - @SubscribeMessage('show quiz') - async handleShowQuiz(client: Socket, payload: ShowQuizRequestDto) { - const { pinCode } = payload; + @SubscribeMessage('start quiz') + async handleStartQuiz(client: Socket, payload: StartQuizRequestDto) { + const { sid, pinCode } = payload; + + const { pinCode: storedPinCode } = JSON.parse(await this.redisService.get(`master_sid=${sid}`)); + + if (storedPinCode !== pinCode) { + console.log('Invalid pinCode'); + } + + // 퀴즈 진행 상태로 변경 const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); + gameInfo.gameStatus = GAMESTATUS_TYPES.IN_PROGRESS; const { classId, currentOrder, quizMaxNum } = gameInfo; + + const updatedCurrentOrder = currentOrder + 1; + gameInfo.currentOrder = updatedCurrentOrder; + await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); + // TODO:캐싱된 퀴즈를 가져온다. 퀴즈를 생성할 경우, 만들어졌을거라 예상 // 만일 레디스에 퀴즈가 저장되어있지않다면, 퀴즈를 다시 캐싱해오는 로직이 필요할지도. + // 퀴즈 데이터 가져오기, 초이스 개수를 알아야하기 위해 -> 이후 초이스 배열 만들어야함 const quizData = JSON.parse(await this.redisService.get(`classId=${classId}`)); - - const currentQuizData = quizData[currentOrder]; - const currentTimeLimit = currentQuizData['timeLimit']; + console.log('upadate', updatedCurrentOrder); + const currentQuizData = quizData[updatedCurrentOrder]; const choicesLength = currentQuizData['choices'].length; const choiceStatus = Object.fromEntries( Array.from({ length: choicesLength }, (_, i) => [i, 0]), ); + const startTime = Date.now(); const gameStatus = { totalSubmit: 0, @@ -184,54 +209,72 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect { choiceStatus, submitHistory: [], emojiStatus: { easy: 0, hard: 0 }, + startTime, }; + await this.redisService.set( - `gameId=${pinCode}:quizId=${currentOrder}`, + `gameId=${pinCode}:quizId=${updatedCurrentOrder}`, JSON.stringify(gameStatus), ); + console.log('start quiz', client.id, updatedCurrentOrder); + // 마스터가 참여자들에게 게임 시작을 알림, 이 알림을 받은 참여자는 showranking을 시작한다. + client.to(pinCode).emit('start quiz', { isStarted: true }); + return { isStarted: true }; + } - const isLast = gameInfo.currentOrder === quizMaxNum ? true : false; - this.server.to(pinCode).emit('show quiz', { quizMaxNum, currentQuizData, isLast }); + // @SubscribeMessage('time end') + // async handleTimeEnd(client: Socket, payload: any) { + // const { pinCode } = payload; + // const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); + // await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); + // } - const startTime = Date.now(); - await this.intervalTimeSender(pinCode, startTime, currentTimeLimit); - } + @SubscribeMessage('show quiz') + async handleShowQuiz(client: Socket, payload: ShowQuizRequestDto) { + const { pinCode } = payload; + const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); - async intervalTimeSender(pinCode: string, startTime: number, timeLimit: number) { - const intervalId = setInterval(async () => { - const currentTime = Date.now(); - const elapsedTime = currentTime - startTime; - const remainingTime = (timeLimit + QUIZ_WAITING_TIME) * 1000 - elapsedTime; - if (remainingTime <= 0) { - const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); - - gameInfo.currentOrder += 1; - await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); - this.server.to(pinCode).emit('time end', { isEnd: true }); - clearInterval(intervalId); - return; - } - this.server.to(pinCode).emit('timer tick', { currentTime, elapsedTime, remainingTime }); - }, INTERVAL_TIME); - } + const { classId, currentOrder, quizMaxNum } = gameInfo; + // TODO:캐싱된 퀴즈를 가져온다. 퀴즈를 생성할 경우, 만들어졌을거라 예상 + // 만일 레디스에 퀴즈가 저장되어있지않다면, 퀴즈를 다시 캐싱해오는 로직이 필요할지도. - @SubscribeMessage('start quiz') - async handleStartQuiz(client: Socket, payload: StartQuizRequestDto) { - const { sid, pinCode } = payload; + // 퀴즈 데이터 가져오기 이건 참여자들에게 보여줄려고 get한 데이터 + const quizData = JSON.parse(await this.redisService.get(`classId=${classId}`)); - const { pinCode: storedPinCode } = JSON.parse(await this.redisService.get(`master_sid=${sid}`)); + const currentQuizData = quizData[currentOrder]; - if (storedPinCode !== pinCode) { - console.log('Invalid pinCode'); - } + const isLast = gameInfo.currentOrder === quizMaxNum ? true : false; - client.to(pinCode).emit('start quiz', { isStarted: true }); + console.log('show quiz before', client.id, currentOrder); + // 기존에 퀴즈가 저장된적이 있는지. start quiz에 저장되어있음 + const quizRedis = JSON.parse( + await this.redisService.get(`gameId=${pinCode}:quizId=${currentOrder}`), + ); + console.log('show quiz', client.id, quizRedis); - const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); - gameInfo.gameStatus = GAMESTATUS_TYPES.IN_PROGRESS; - await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); + const startTime = quizRedis.startTime; + return { quizMaxNum, currentQuizData, startTime, isLast }; + // await this.intervalTimeSender(pinCode, startTime, currentTimeLimit); } + // async intervalTimeSender(pinCode: string, startTime: number, timeLimit: number) { + // const intervalId = setInterval(async () => { + // const currentTime = Date.now(); + // const elapsedTime = currentTime - startTime; + // const remainingTime = (timeLimit + QUIZ_WAITING_TIME) * 1000 - elapsedTime; + // if (remainingTime <= 0) { + // const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); + + // gameInfo.currentOrder += 1; + // await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); + // this.server.to(pinCode).emit('time end', { isEnd: true }); + // clearInterval(intervalId); + // return; + // } + // this.server.to(pinCode).emit('timer tick', { currentTime, elapsedTime, remainingTime }); + // }, INTERVAL_TIME); + // } + private async storeQuizToRedis(classId: number) { const cachedQuizData = await this.redisService.get(`classId=${classId}`);