diff --git a/apps/backend/src/quiz-zone/quiz-zone.controller.spec.ts b/apps/backend/src/quiz-zone/quiz-zone.controller.spec.ts index b46f80cf0..0f307e34b 100644 --- a/apps/backend/src/quiz-zone/quiz-zone.controller.spec.ts +++ b/apps/backend/src/quiz-zone/quiz-zone.controller.spec.ts @@ -81,8 +81,11 @@ describe('QuizZoneController', () => { stage: 'LOBBY', playerCount: 1, maxPlayers: 8, + serverTime: 0, }; + Date.now = jest.fn().mockReturnValue(0); + it('퀴즈존 정보를 성공적으로 조회한다', async () => { const session = { id: 'sessionId' }; mockQuizZoneService.getQuizZoneInfo.mockResolvedValue(mockQuizZoneInfo); diff --git a/apps/backend/src/quiz-zone/quiz-zone.controller.ts b/apps/backend/src/quiz-zone/quiz-zone.controller.ts index 415ea4e91..ab8d6da79 100644 --- a/apps/backend/src/quiz-zone/quiz-zone.controller.ts +++ b/apps/backend/src/quiz-zone/quiz-zone.controller.ts @@ -68,12 +68,16 @@ export class QuizZoneController { @Session() session: Record, @Param('quizZoneId') quizZoneId: string, ) { - const quizZoneInfo = this.quizZoneService.getQuizZoneInfo( + const serverTime = Date.now(); + const quizZoneInfo = await this.quizZoneService.getQuizZoneInfo( session.id, quizZoneId, session.quizZoneId, ); session['quizZoneId'] = quizZoneId; - return quizZoneInfo; + return { + ...quizZoneInfo, + serverTime, + }; } } diff --git a/apps/frontend/src/blocks/QuizZone/QuizInProgress.tsx b/apps/frontend/src/blocks/QuizZone/QuizInProgress.tsx index 1d0ab98ba..7c5d726a1 100644 --- a/apps/frontend/src/blocks/QuizZone/QuizInProgress.tsx +++ b/apps/frontend/src/blocks/QuizZone/QuizInProgress.tsx @@ -3,11 +3,13 @@ import ContentBox from '@/components/common/ContentBox'; import Input from '@/components/common/Input'; import ProgressBar from '@/components/common/ProgressBar'; import Typography from '@/components/common/Typogrpahy'; -import { useState } from 'react'; +import { useTimer } from '@/hook/useTimer'; +import { CurrentQuiz } from '@/types/quizZone.types'; +import { useEffect, useState } from 'react'; interface QuizInProgressProps { playTime: number | null; - currentQuiz: any; + currentQuiz: CurrentQuiz; submitAnswer: (e: any) => void; } @@ -17,6 +19,17 @@ const QuizInProgress = ({ currentQuiz, submitAnswer }: QuizInProgressProps) => { const MAX_TEXT_LENGTH = 100; const MIN_TEXT_LENGTH = 1; + const playTime = currentQuiz.playTime; + + const { start, time } = useTimer({ + initialTime: playTime / 1000, + onComplete: () => {}, + }); + + useEffect(() => { + start(); + }, []); + const handleSubmitAnswer = () => { if (answer.length >= MIN_TEXT_LENGTH && answer.length <= MAX_TEXT_LENGTH) { submitAnswer(answer); @@ -25,7 +38,7 @@ const QuizInProgress = ({ currentQuiz, submitAnswer }: QuizInProgressProps) => { return (
- {}} /> + {}} /> ); diff --git a/apps/frontend/src/components/common/ProgressBar.stories.tsx b/apps/frontend/src/components/common/ProgressBar.stories.tsx index 7f2f5849a..efc4e6515 100644 --- a/apps/frontend/src/components/common/ProgressBar.stories.tsx +++ b/apps/frontend/src/components/common/ProgressBar.stories.tsx @@ -13,7 +13,8 @@ type Story = StoryObj; export const Default: Story = { args: { - deadlineTime: Date.now() + 30000, + playTime: 30000, + time: Date.now(), onTimeEnd: () => { alert('time end'); }, diff --git a/apps/frontend/src/components/common/ProgressBar.tsx b/apps/frontend/src/components/common/ProgressBar.tsx index 6833349b7..6d5e002ee 100644 --- a/apps/frontend/src/components/common/ProgressBar.tsx +++ b/apps/frontend/src/components/common/ProgressBar.tsx @@ -1,83 +1,39 @@ -import { Progress } from '../ui/progress'; -import { useState, useEffect, useRef } from 'react'; +import { Progress } from '@/components/ui/progress'; import Typography from './Typogrpahy'; export interface ProgressBarProps { - deadlineTime: number; // ISO string or Date object + playTime: number; + time: number; onTimeEnd?: () => void; } /** * @description - * ProgressBar 컴포넌트는 주어진 마감 시간까지 남은 시간을 시각적으로 표시합니다. - * 현재 시간과 마감 시간 사이의 진행도를 보여주며, 시간이 다 되면 콜백을 실행합니다. + * ProgressBar 컴포넌트는 주어진 플레이 시간과 현재 시간을 기반으로 진행도를 시각적으로 표시합니다. * * @example * console.log('Time ended!')} * /> */ -const ProgressBar = ({ deadlineTime, onTimeEnd }: ProgressBarProps) => { - const totalDurationRef = useRef(deadlineTime - Date.now()); +const ProgressBar = ({ playTime, time, onTimeEnd }: ProgressBarProps) => { + // 진행도 계산 (남은 시간 / 전체 시간 * 100) + const progress = Math.max(0, Math.min(100, (time / playTime) * 100000)); - const calculateInitialProgress = () => { - const now = Date.now(); - const totalDuration = totalDurationRef.current; - const remaining = deadlineTime - now; - const progress = Math.max(0, (remaining / totalDuration) * 100); - return progress; - }; + // 시간이 다 되었을 때 콜백 실행 + if (time <= 0) { + onTimeEnd?.(); + } - const calculateInitialSeconds = () => { - const now = Date.now(); - return Math.max(0, (deadlineTime - now) / 1000); - }; - - const [progress, setProgress] = useState(calculateInitialProgress()); - const [remainingSeconds, setRemainingSeconds] = useState(calculateInitialSeconds()); - - useEffect(() => { - const startTime = Date.now(); - const totalDuration = totalDurationRef.current; - - // deadlineTime이 이미 지난 경우 - if (deadlineTime <= startTime) { - setProgress(0); - setRemainingSeconds(0); - onTimeEnd?.(); - return; - } - - const updateProgress = () => { - const currentTime = Date.now(); - const remaining = deadlineTime - currentTime; - - if (remaining <= 0) { - setProgress(0); - setRemainingSeconds(0); - onTimeEnd?.(); - return false; - } - - // totalDurationRef.current를 사용하여 일정한 비율로 감소 - const newProgress = (remaining / totalDuration) * 100; - setProgress(Math.max(0, Math.min(100, newProgress))); - setRemainingSeconds(remaining / 1000); - - return true; - }; - - const interval = setInterval(updateProgress, 100); - - return () => clearInterval(interval); - }, [deadlineTime, onTimeEnd]); + console.log(progress); return (
({ + default: () => ({ + beginConnection: vi.fn(), + sendMessage: vi.fn(), + closeConnection: vi.fn(), + messageHandler: vi.fn(), + }), +})); + +// env 모킹 +vi.mock('@/utils/atob', () => ({ + default: vi.fn((str) => str), +})); + +describe('useQuizZone', () => { + const mockQuizZoneId = 'test-quiz-zone-id'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('초기 상태가 올바르게 설정되어야 합니다', () => { + const { result } = renderHook(() => useQuizZone(mockQuizZoneId)); + + expect(result.current.quizZoneState).toEqual({ + stage: 'LOBBY', + currentPlayer: { + id: '', + nickname: '', + }, + title: '', + description: '', + hostId: '', + quizCount: 0, + players: [], + score: 0, + submits: [], + quizzes: [], + chatMessages: [], + maxPlayers: 0, + offset: 0, + serverTime: 0, + }); + }); + + it('playQuiz 액션이 상태를 올바르게 업데이트해야 합니다', () => { + const { result } = renderHook(() => useQuizZone(mockQuizZoneId)); + + act(() => { + result.current.playQuiz(); + }); + + expect(result.current.quizZoneState.stage).toBe('IN_PROGRESS'); + expect(result.current.quizZoneState.currentPlayer.state).toBe('PLAY'); + }); + + it('init 액션이 상태를 올바르게 업데이트해야 합니다', () => { + const { result } = renderHook(() => useQuizZone(mockQuizZoneId)); + + const mockQuizZone: Partial = { + stage: 'LOBBY', + currentPlayer: { + id: 'player1', + nickname: 'Player 1', + state: 'WAIT', + }, + title: 'Test Quiz', + description: 'Test Description', + hostId: 'host1', + quizCount: 5, + serverTime: Date.now(), + }; + + act(() => { + result.current.initQuizZoneData(mockQuizZone as QuizZone, Date.now()); + }); + + expect(result.current.quizZoneState.title).toBe('Test Quiz'); + expect(result.current.quizZoneState.currentPlayer.id).toBe('player1'); + expect(result.current.quizZoneState.quizCount).toBe(5); + }); + + describe('state transitions', () => { + it('LOBBY에서 IN_PROGRESS로 상태 전환이 올바르게 되어야 합니다', () => { + const { result } = renderHook(() => useQuizZone(mockQuizZoneId)); + + expect(result.current.quizZoneState.stage).toBe('LOBBY'); + + act(() => { + result.current.playQuiz(); + }); + + expect(result.current.quizZoneState.stage).toBe('IN_PROGRESS'); + }); + }); +}); diff --git a/apps/frontend/src/hook/quizZone/useQuizZone.tsx b/apps/frontend/src/hook/quizZone/useQuizZone.tsx index 3a18a2a34..7451ea345 100644 --- a/apps/frontend/src/hook/quizZone/useQuizZone.tsx +++ b/apps/frontend/src/hook/quizZone/useQuizZone.tsx @@ -2,6 +2,7 @@ import { useReducer } from 'react'; import useWebSocket from '@/hook/useWebSocket.tsx'; import { ChatMessage, + InitQuizZoneResponse, NextQuizResponse, Player, QuizZone, @@ -12,7 +13,7 @@ import { import atob from '@/utils/atob'; export type QuizZoneAction = - | { type: 'init'; payload: QuizZone } + | { type: 'init'; payload: InitQuizZoneResponse } | { type: 'join'; payload: Player[] } | { type: 'someone_join'; payload: Player } | { type: 'someone_leave'; payload: string } @@ -39,23 +40,19 @@ const quizZoneReducer: Reducer = (state, action) => { switch (type) { case 'init': + const { quizZone, now } = payload; + const receiveTime = new Date().getTime(); return { ...state, - stage: payload.stage, - title: payload.title, - description: payload.description, - quizCount: payload.quizCount, - hostId: payload.hostId, - currentPlayer: payload.currentPlayer, - chatMessages: payload.chatMessages, + ...quizZone, currentQuiz: - payload.currentQuiz !== undefined + quizZone.currentQuiz !== undefined ? { - ...payload.currentQuiz, - question: atob(payload.currentQuiz?.question ?? ''), + ...quizZone.currentQuiz, + question: atob(quizZone.currentQuiz?.question ?? ''), } : undefined, - maxPlayers: payload.maxPlayers, + offset: quizZone.serverTime - (now + receiveTime) / 2, players: [], }; case 'join': @@ -121,8 +118,8 @@ const quizZoneReducer: Reducer = (state, action) => { question: atob(nextQuiz.question), currentIndex: nextQuiz.currentIndex, playTime: nextQuiz.playTime, - startTime: nextQuiz.startTime, - deadlineTime: nextQuiz.deadlineTime, + startTime: nextQuiz.startTime - state.offset, + deadlineTime: nextQuiz.deadlineTime - state.offset, quizType: 'SHORT', }, currentQuizResult: { @@ -162,7 +159,7 @@ const quizZoneReducer: Reducer = (state, action) => { submits: payload.submits, quizzes: payload.quizzes, ranks: payload.ranks, - endSocketTime: payload.endSocketTime, + endSocketTime: payload.endSocketTime - state.offset, }; case 'chat': return { @@ -241,6 +238,8 @@ const useQuizZone = (quizZoneId: string, handleReconnect?: () => void) => { quizzes: [], chatMessages: [], maxPlayers: 0, + offset: 0, + serverTime: 0, }; const [quizZoneState, dispatch] = useReducer(quizZoneReducer, initialQuizZoneState); @@ -267,8 +266,8 @@ const useQuizZone = (quizZoneId: string, handleReconnect?: () => void) => { }); //initialize QuizZOne - const initQuizZoneData = async (quizZone: QuizZone) => { - dispatch({ type: 'init', payload: quizZone }); + const initQuizZoneData = async (quizZone: QuizZone, now: number) => { + dispatch({ type: 'init', payload: { quizZone, now } }); beginConnection(); joinQuizZone({ quizZoneId }); }; diff --git a/apps/frontend/src/hook/useTimer.test.ts b/apps/frontend/src/hook/useTimer.test.ts deleted file mode 100644 index 371442aa6..000000000 --- a/apps/frontend/src/hook/useTimer.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -// useTimer.test.ts -import { renderHook, act } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { useTimer } from './useTimer'; - -describe('useTimer', () => { - beforeEach(() => { - // 가짜 타이머 설정 - vi.useFakeTimers(); - }); - - afterEach(() => { - // 테스트 간 타이머 정리 - vi.clearAllTimers(); - vi.restoreAllMocks(); - }); - - it('초기 시간이 올바르게 설정되어야 한다', () => { - const { result } = renderHook(() => - useTimer({ - initialTime: 10, - onComplete: () => {}, - }), - ); - - expect(result.current.time).toBe(10); - }); - - it('start 호출 시 카운트다운이 시작되어야 한다', () => { - const { result } = renderHook(() => - useTimer({ - initialTime: 10, - onComplete: () => {}, - }), - ); - - act(() => { - result.current.start(); - }); - - act(() => { - vi.advanceTimersByTime(100); - }); - - expect(result.current.time).toBe(9.9); - }); - - it('타이머가 0에 도달하면 onComplete가 한 번만 호출되어야 한다', () => { - const onComplete = vi.fn(); - const { result } = renderHook(() => - useTimer({ - initialTime: 0.2, - onComplete, - }), - ); - - act(() => { - result.current.start(); - }); - - // 타이머가 0에 도달할 때까지 시간 진행 - act(() => { - vi.advanceTimersByTime(200); - }); - - // onComplete가 정확히 한 번만 호출되었는지 확인 - expect(onComplete).toHaveBeenCalledTimes(1); - }); - - it('컴포넌트 언마운트 시 모든 타이머가 정리되어야 한다', () => { - const { unmount } = renderHook(() => - useTimer({ - initialTime: 10, - onComplete: () => {}, - }), - ); - - unmount(); - - expect(vi.getTimerCount()).toBe(0); - }); -}); diff --git a/apps/frontend/src/hook/useTimer.ts b/apps/frontend/src/hook/useTimer.ts index 13ba22dd7..4f7e248a3 100644 --- a/apps/frontend/src/hook/useTimer.ts +++ b/apps/frontend/src/hook/useTimer.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; interface TimerConfig { initialTime: number; @@ -7,67 +7,87 @@ interface TimerConfig { } /** - * 카운트다운 타이머를 관리하는 커스텀 훅입니다. - * - * @description - * 이 훅은 시작 제어 기능을 갖춘 카운트다운 타이머 기능을 제공합니다. - * 초기 시간과 타이머 완료 시 실행될 선택적 콜백을 받습니다. - * - * @example - * ```typescript - * const { time, start } = useTimer({ - * initialTime: 60, - * onComplete: () => console.log('타이머 완료!'), - * }); - * - * // 타이머 시작 - * start(); - * ``` + * Web Worker를 활용한 정확한 카운트다운 타이머 커스텀 훅 * * @param {TimerConfig} config - 타이머 설정 객체 - * @param {number} config.initialTime - 카운트다운 초기 시간(초 단위) - * @param {() => void} [config.onComplete] - 타이머 완료 시 실행될 선택적 콜백 - * - * @returns {object} 현재 시간과 시작 함수를 포함하는 객체 - * @returns {number} returns.time - 카운트다운의 현재 시간 - * @returns {() => void} returns.start - 타이머를 시작하는 함수 + * @returns {object} 타이머 상태와 컨트롤 함수들 */ - export const useTimer = ({ initialTime, onComplete }: TimerConfig) => { const [time, setTime] = useState(initialTime); const [isRunning, setIsRunning] = useState(false); + const workerRef = useRef(null); useEffect(() => { - let timer: NodeJS.Timeout | null = null; - - if (isRunning) { - timer = setInterval(() => { - setTime((prev) => { - const nextTime = prev - 0.1; - if (nextTime <= 0) { - setIsRunning(false); - onComplete?.(); - return 0; - } - return nextTime; - }); - }, 100); + // Worker가 이미 존재하면 종료 + if (workerRef.current) { + workerRef.current.terminate(); } - return () => { - if (timer) { - clearInterval(timer); + // 새 Worker 생성 + workerRef.current = new Worker(new URL('../workers/timer.worker.ts', import.meta.url), { + type: 'module', + }); + + // Worker 메시지 핸들러 + workerRef.current.onmessage = (event) => { + const { type, payload } = event.data; + // console.log('Received from worker:', type, payload); // 디버깅용 + + switch (type) { + case 'TICK': + setTime(payload.time); + break; + case 'COMPLETE': + setTime(0); + setIsRunning(false); + onComplete?.(); + break; } }; - }, [isRunning, onComplete]); + // Clean up + return () => { + workerRef.current?.terminate(); + }; + }, []); + + // 타이머 시작 const start = useCallback(() => { - if (isRunning) return; + if (isRunning || !workerRef.current) return; + + workerRef.current.postMessage({ + type: 'START', + payload: { + duration: initialTime, + serverTime: Date.now(), + }, + }); + setIsRunning(true); + }, [isRunning, initialTime]); + + // 타이머 정지 + const stop = useCallback(() => { + if (!isRunning || !workerRef.current) return; + + workerRef.current.postMessage({ type: 'STOP' }); + setIsRunning(false); }, [isRunning]); + // 타이머 리셋 + const reset = useCallback(() => { + if (!workerRef.current) return; + + workerRef.current.postMessage({ type: 'RESET' }); + setTime(initialTime); + setIsRunning(false); + }, [initialTime]); + return { time, + isRunning, start, + stop, + reset, }; }; diff --git a/apps/frontend/src/hook/useValidInput.test.ts b/apps/frontend/src/hook/useValidInput.test.ts new file mode 100644 index 000000000..6b69f63d8 --- /dev/null +++ b/apps/frontend/src/hook/useValidInput.test.ts @@ -0,0 +1,124 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, test, expect } from 'vitest'; +import useValidState from './useValidInput'; + +describe('useValidState', () => { + test('초기값이 유효한 경우', () => { + const validator = (value: number) => { + if (value < 0) return '음수는 입력할 수 없습니다.'; + }; + + const { result } = renderHook(() => useValidState(10, validator)); + const [state, errorMessage, _, isInvalid] = result.current; + + expect(state).toBe(10); + expect(errorMessage).toBe(''); + expect(isInvalid).toBe(false); + }); + + test('초기값이 유효하지 않은 경우', () => { + const validator = (value: number) => { + if (value < 0) return '음수는 입력할 수 없습니다.'; + }; + + const { result } = renderHook(() => useValidState(-5, validator)); + const [state, errorMessage, _, isInvalid] = result.current; + + expect(state).toBe(-5); + expect(errorMessage).toBe('음수는 입력할 수 없습니다.'); + expect(isInvalid).toBe(true); + }); + + test('유효한 값으로 상태 업데이트', () => { + const validator = (value: number) => { + if (value < 0) return '음수는 입력할 수 없습니다.'; + }; + + const { result } = renderHook(() => useValidState(0, validator)); + + act(() => { + const [, , setValue] = result.current; + setValue(5); + }); + + const [state, errorMessage, _, isInvalid] = result.current; + expect(state).toBe(5); + expect(errorMessage).toBe(''); + expect(isInvalid).toBe(false); + }); + + test('유효하지 않은 값으로 상태 업데이트', () => { + const validator = (value: number) => { + if (value < 0) return '음수는 입력할 수 없습니다.'; + }; + + const { result } = renderHook(() => useValidState(0, validator)); + + act(() => { + const [, , setValue] = result.current; + setValue(-10); + }); + + const [state, errorMessage, _, isInvalid] = result.current; + expect(state).toBe(-10); + expect(errorMessage).toBe('음수는 입력할 수 없습니다.'); + expect(isInvalid).toBe(true); + }); + + test('다중 조건 검증', () => { + const validator = (value: number) => { + if (value < 0) return '음수는 입력할 수 없습니다.'; + if (value > 100) return '100보다 큰 숫자는 입력할 수 없습니다.'; + }; + + const { result } = renderHook(() => useValidState(0, validator)); + + // 유효한 값 테스트 + act(() => { + const [, , setValue] = result.current; + setValue(50); + }); + + expect(result.current[0]).toBe(50); + expect(result.current[1]).toBe(''); + expect(result.current[3]).toBe(false); + + // 범위를 초과하는 값 테스트 + act(() => { + const [, , setValue] = result.current; + setValue(150); + }); + + expect(result.current[0]).toBe(150); + expect(result.current[1]).toBe('100보다 큰 숫자는 입력할 수 없습니다.'); + expect(result.current[3]).toBe(true); + }); + + test('문자열 유효성 검사', () => { + const validator = (value: string) => { + if (value.length < 3) return '최소 3글자 이상이어야 합니다.'; + }; + + const { result } = renderHook(() => useValidState('', validator)); + + // 유효하지 않은 값 테스트 + act(() => { + const [, , setValue] = result.current; + setValue('ab'); + }); + + expect(result.current[0]).toBe('ab'); + expect(result.current[1]).toBe('최소 3글자 이상이어야 합니다.'); + expect(result.current[3]).toBe(true); + + // 유효한 값 테스트 + act(() => { + const [, , setValue] = result.current; + setValue('abc'); + }); + + expect(result.current[0]).toBe('abc'); + expect(result.current[1]).toBe(''); + expect(result.current[3]).toBe(false); + }); +}); diff --git a/apps/frontend/src/pages/NotFoundPage.tsx b/apps/frontend/src/pages/NotFoundPage.tsx new file mode 100644 index 000000000..705b0a020 --- /dev/null +++ b/apps/frontend/src/pages/NotFoundPage.tsx @@ -0,0 +1,50 @@ +import CommonButton from '@/components/common/CommonButton'; +import ContentBox from '@/components/common/ContentBox'; +import Typography from '@/components/common/Typogrpahy'; +import TooltipWrapper from '@/components/common/TooltipWrapper'; +import { useNavigate } from 'react-router-dom'; +import Logo from '@/components/common/Logo'; + +const NotFound = () => { + const navigate = useNavigate(); + const handleMoveMainPage = () => { + navigate(`/`); + }; + + return ( +
+ + + + + + + + + + + + + +
+ ); +}; + +export default NotFound; diff --git a/apps/frontend/src/pages/QuizZonePage.tsx b/apps/frontend/src/pages/QuizZonePage.tsx index 1bf3a30c2..cdd673b73 100644 --- a/apps/frontend/src/pages/QuizZonePage.tsx +++ b/apps/frontend/src/pages/QuizZonePage.tsx @@ -37,8 +37,8 @@ const QuizZoneContent = () => { setIsLoading(true); const quizZone = await requestQuizZone(quizZoneId); - - await initQuizZoneData(quizZone); + const now = new Date().getTime(); + await initQuizZoneData(quizZone, now); setIsLoading(false); setIsDisconnection(false); diff --git a/apps/frontend/src/router/router.tsx b/apps/frontend/src/router/router.tsx index d6c3453d8..81bddd9ce 100644 --- a/apps/frontend/src/router/router.tsx +++ b/apps/frontend/src/router/router.tsx @@ -3,6 +3,7 @@ import MainPage from '@/pages/MainPage'; import RootLayout from '../pages/RootLayout'; import QuizZonePage from '@/pages/QuizZonePage'; import CreateQuizZonePage from '@/pages/CreateQuizZonePage.tsx'; +import NotFound from '@/pages/NotFoundPage'; function Router() { return ( @@ -11,6 +12,7 @@ function Router() { } /> } /> } /> + } /> ); diff --git a/apps/frontend/src/types/quizZone.types.ts b/apps/frontend/src/types/quizZone.types.ts index aeb13ec32..c55c2c08a 100644 --- a/apps/frontend/src/types/quizZone.types.ts +++ b/apps/frontend/src/types/quizZone.types.ts @@ -27,6 +27,8 @@ export interface QuizZone { chatMessages?: ChatMessage[]; isQuizZoneEnd?: boolean; endSocketTime?: number; + serverTime: number; + offset: number; } export interface Rank { @@ -119,3 +121,8 @@ export interface ChatMessage { nickname: string; message: string; } + +export interface InitQuizZoneResponse { + quizZone: QuizZone; + now: number; +} diff --git a/apps/frontend/src/types/timer.types.ts b/apps/frontend/src/types/timer.types.ts new file mode 100644 index 000000000..63ad825b3 --- /dev/null +++ b/apps/frontend/src/types/timer.types.ts @@ -0,0 +1,14 @@ +export interface TimerMessage { + type: 'START' | 'STOP' | 'RESET'; + payload?: { + duration: number; + serverTime?: number; + }; +} + +export interface TimerResponse { + type: 'TICK' | 'COMPLETE'; + payload?: { + time: number; + }; +} diff --git a/apps/frontend/src/workers/timer.worker.ts b/apps/frontend/src/workers/timer.worker.ts new file mode 100644 index 000000000..32ede9bcb --- /dev/null +++ b/apps/frontend/src/workers/timer.worker.ts @@ -0,0 +1,94 @@ +import { TimerMessage, TimerResponse } from '@/types/timer.types'; + +class TimerWorker { + private timerId: ReturnType | null = null; + private startTime: number | null = null; + private duration: number | null = null; + private timeOffset: number = 0; + private pausedTimeRemaining: number | null = null; + + constructor() { + self.onmessage = this.handleMessage.bind(this); + } + + private handleMessage(event: MessageEvent) { + const { type, payload } = event.data; + + switch (type) { + case 'START': + if (!payload?.duration) return; + + if (this.pausedTimeRemaining !== null) { + this.startTimer(this.pausedTimeRemaining); + this.pausedTimeRemaining = null; + } else { + if (payload.serverTime) { + this.timeOffset = Date.now() - payload.serverTime; + } + this.startTimer(payload.duration); + } + break; + + case 'STOP': + if (this.timerId !== null && this.startTime !== null && this.duration !== null) { + const currentTime = Date.now() - this.timeOffset; + const elapsed = (currentTime - this.startTime) / 1000; + this.pausedTimeRemaining = Math.max(0, this.duration - elapsed); + } + this.stopTimer(); + break; + + case 'RESET': + this.resetTimer(); + break; + } + } + + private startTimer(duration: number) { + this.stopTimer(); // 기존 타이머가 있다면 정리 + + this.duration = duration; + this.startTime = Date.now() - this.timeOffset; + + // setInterval 직접 사용 + this.timerId = setInterval(() => { + if (!this.startTime || !this.duration) return; + + const currentTime = Date.now() - this.timeOffset; + const elapsed = (currentTime - this.startTime) / 1000; + const remaining = Math.max(0, this.duration - elapsed); + const roundedRemaining = Math.round(remaining * 10) / 10; + + if (roundedRemaining <= 0) { + this.postMessage({ type: 'COMPLETE' }); + this.stopTimer(); + } else { + this.postMessage({ + type: 'TICK', + payload: { time: roundedRemaining }, + }); + } + }, 100); + } + + private stopTimer() { + if (this.timerId !== null) { + clearInterval(this.timerId); + this.timerId = null; + } + } + + private resetTimer() { + this.stopTimer(); + this.startTime = null; + this.duration = null; + this.timeOffset = 0; + this.pausedTimeRemaining = null; + } + + private postMessage(message: TimerResponse) { + self.postMessage(message); + } +} + +new TimerWorker(); diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index 75ade8bf1..5bd43f4db 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -6,7 +6,8 @@ "paths": { "@/*": ["./src/*"] }, - "types": ["vitest/globals", "@testing-library/jest-dom"] + "types": ["vitest/globals", "@testing-library/jest-dom"], + "lib": ["webworker", "es2015"] }, "files": [], "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 887d59784..d3d322823 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -7,6 +7,9 @@ export default defineConfig(({ mode }) => { return { plugins: [react()], + worker: { + format: 'es', + }, resolve: { alias: { '@': path.resolve(__dirname, './src'),