Skip to content

Commit

Permalink
[FE] - 마스터, 참가자 퀴즈 세션 새로고침 반영 (#157)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
chan-byeong and dooohun authored Dec 3, 2024
1 parent 95505c1 commit aa3ae79
Show file tree
Hide file tree
Showing 17 changed files with 551 additions and 340 deletions.
55 changes: 55 additions & 0 deletions packages/client/src/pages/quiz-master-session/index.lazy.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="w-screen min-h-screen">
<QuizMasterHeader
quizData={quiz.currentQuizData}
startTime={quiz.startTime}
timeLimit={quiz.currentQuizData.timeLimit}
handleNextQuiz={handleNextQuiz}
pinCode={pinCode as string}
socket={socket}
/>
<Statistics
quizData={quiz.currentQuizData}
initializeStates={initializeStates}
setInitializeStates={setInitializeStates}
/>
</div>
);
}
141 changes: 5 additions & 136 deletions packages/client/src/pages/quiz-master-session/index.tsx
Original file line number Diff line number Diff line change
@@ -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<MasterStatisticsResponse>(INITIAL_MASTER_STATISTICS);
const [quizData, setQuizData] = useState<QuizData>(INITIAL_QUIZ_DATA);
const [tick, setTick] = useState<TimerTickResponse>(INITIAL_TICK);
const [quizIndex, setQuizIndex] = useState(0);
const [isLastQuiz, setIsLastQuiz] = useState(false);
const [reactionStats, setReactionStats] = useState(INITIAL_EMOJI);
const [history, setHistory] = useState<HistoryItem[]>([]);

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 (
<div className="w-screen min-h-screen">
<div className="p-5">
<div className="flex justify-between">
<div>
<h1 className="text-xl font-bold mb-2">실시간 통계</h1>
<p className="text-2xl font-bold mb-2">
Q{quizIndex + 1}. {quizData.content}
</p>
</div>
<div>
<p className="font-bold text-gray-500 mb-2">
제한 시간{' '}
{Math.floor(tick.remainingTime / 1000) === 0
? '종료'
: Math.floor(tick.remainingTime / 1000)}
</p>
<div className="mb-2">
<button
className={`bg-blue-500 text-white px-4 py-2 rounded-md disabled:bg-blue-300 disabled:cursor-not-allowed disabled:opacity-50`}
onClick={handleNextQuiz}
disabled={Math.floor(tick.remainingTime / 1000) !== 0}
>
다음 퀴즈
</button>
</div>
</div>
</div>
</div>
<StatisticsGroup participantStatistics={masterStatistics} />
<div className="grid grid-cols-[3fr_1fr] gap-4 mx-5 h-[calc(100vh-300px)]">
<AnswerGraph
answerStats={masterStatistics.choiceStatus}
participantCount={masterStatistics.participantLength}
quizData={quizData}
/>
<div>
<RecentSubmittedAnswers
userSubmitHistory={masterStatistics.submitHistory}
history={history}
setHistory={setHistory}
/>
<EmojiChart reactionStats={reactionStats} />
</div>
</div>
</div>
<AsyncBoundary>
<QuizMasterSessionLazyPage />
</AsyncBoundary>
);
}
59 changes: 32 additions & 27 deletions packages/client/src/pages/quiz-master-session/ui/AnswerChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 (
<div className="flex items-center justify-center h-full">
<LoadingSpinner width={50} height={50} />
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={answerStatsArray} barSize={60}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="answer" axisLine={false} tickLine={false} />
<XAxis
dataKey="answer"
axisLine={false}
tickLine={false}
tick={({ x, y, payload }) => {
const answerData = answerStatsArray.find((item) => item.answer === payload.value);
const isCorrect = answerData?.isCorrect;
return (
<text
x={x}
y={y + 15}
textAnchor="middle"
fill={isCorrect ? 'green' : '#000'}
fontWeight={isCorrect ? 'bold' : 'normal'}
>
{payload.value}
</text>
);
}}
/>
<YAxis
axisLine={false}
tickLine={false}
Expand All @@ -67,7 +68,11 @@ export default function AnswerGraph({ answerStats, quizData, participantCount }:
/>
<Tooltip formatter={(value: number) => [`${value}명`, '참여자 수']} />
<Legend formatter={() => '참여자 수'} />
<Bar dataKey="count" fillOpacity={0.8} />
<Bar dataKey="count" fillOpacity={0.8} isAnimationActive>
{answerStatsArray.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.isCorrect ? '#15803D' : '#2C2C2C'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="p-5">
<div className="flex justify-between">
<div>
<h1 className="text-xl font-bold mb-2">실시간 통계</h1>
<p className="text-2xl font-bold mb-2">
Q{quizData.position + 1}. {quizData.content}
</p>
</div>
<div>
<p className="font-bold text-gray-500 mb-2">
제한 시간 {remainingTime <= 0 ? '종료' : remainingTime}
</p>
<div className="mb-2">
<button
className={`bg-blue-500 text-white px-4 py-2 rounded-md disabled:bg-blue-300 disabled:cursor-not-allowed disabled:opacity-50`}
onClick={handleNextQuiz}
disabled={remainingTime > 0}
>
다음 퀴즈
</button>
</div>
</div>
</div>
</div>
);
}
Loading

0 comments on commit aa3ae79

Please sign in to comment.