From 7978857910a01915d19640b41e6de4b2d5b1d87b Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Wed, 30 Jul 2025 11:16:39 +0900 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20=EB=B2=A8=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/page/TimerPage/hooks/useBellSound.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/page/TimerPage/hooks/useBellSound.ts b/src/page/TimerPage/hooks/useBellSound.ts index 551f2c0..e746273 100644 --- a/src/page/TimerPage/hooks/useBellSound.ts +++ b/src/page/TimerPage/hooks/useBellSound.ts @@ -50,7 +50,8 @@ export function useBellSound({ prevTime !== null && prevTime > warningTime && currentTime === warningTime && - defaultTime !== warningTime + ((defaultTime !== null && prevTime > defaultTime) || + defaultTime !== warningTime) ); } From 1e78af037fd7fc3c4b43a284e5487ef23aca5ff0 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Wed, 30 Jul 2025 11:16:48 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=EC=9D=BC=EB=B0=98=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=EB=A8=B8=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/page/TimerPage/hooks/useNormalTimer.ts | 40 +++++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/page/TimerPage/hooks/useNormalTimer.ts b/src/page/TimerPage/hooks/useNormalTimer.ts index 5f5b3c6..048a38c 100644 --- a/src/page/TimerPage/hooks/useNormalTimer.ts +++ b/src/page/TimerPage/hooks/useNormalTimer.ts @@ -15,6 +15,7 @@ export function useNormalTimer() { // 타이머에 표시할 '남은 시간'(초) const [timer, setTimer] = useState(null); const intervalRef = useRef(null); + // 타이머가 초기화될 때 사용하는 '기본 시간값' (reset 시 사용) const [defaultTimer, setDefaultTimer] = useState(0); @@ -25,17 +26,44 @@ export function useNormalTimer() { const [isAdditionalTimerOn, setIsAdditionalTimerOn] = useState(false); const [savedTimer, setSavedTimer] = useState(0); + // 실제 시간 계산용 레퍼런스 + const targetTimeRef = useRef(null); + /** * 타이머를 1초마다 1씩 감소시키며 시작 * 이미 동작중이면 재시작하지 않음 */ const startTimer = useCallback(() => { - if (intervalRef.current !== null) return; - intervalRef.current = setInterval(() => { - setTimer((prev) => (prev === null ? null : prev - 1)); - }, 1000); + if (intervalRef.current !== null || timer === null) return; + + // 목표 시각을 실제 시각 기반으로 계산 + // 예를 들어, 현재 시각이 오후 13시 00분 30초인데, 1회당 발언 시간이 30초라면, + // 1회당 발언 시간이 모두 끝나는 시간은 13시 01분 00초이므로, + // 해당 시간을 목표 시간으로 두는 식임 + const startTime = Date.now(); + targetTimeRef.current = startTime + timer * 1000; + + // isRunning 상태를 true로 바꿔주고 인터벌 처리 setIsRunning(true); - }, []); + + // 목표 시각에 기반하여 타이머 계산 + intervalRef.current = setInterval(() => { + // 목표 시각 레퍼런스의 유효성 확인 + if (targetTimeRef.current === null) { + return; + } + + // 현재 시각 확인 + const now = Date.now(); + + // 목표 시각까지 얼마나 더 필요한지, 남은 시간을 초 단위로 계산 + const remainingTotal = targetTimeRef.current - now; + const remainingSeconds = Math.ceil(remainingTotal / 1000); + + // 계산한 남은 시간을 타이머에 반영 + setTimer(remainingSeconds); + }, 200); + }, [timer]); /** * 타이머 일시정지 (setInterval 해제) @@ -96,6 +124,7 @@ export function useNormalTimer() { const handleCloseAdditionalTimer = useCallback(() => { setIsAdditionalTimerOn(false); }, []); + //작전시간 종료 시, 자동으로 타이머 변경 useEffect(() => { if (isAdditionalTimerOn && timer === 0 && isRunning) { @@ -112,6 +141,7 @@ export function useNormalTimer() { setTimer, isRunning, ]); + useEffect(() => () => pauseTimer(), [pauseTimer]); return { From 8121d361904b98443a8e68d9c9da270e860ae898 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Wed, 30 Jul 2025 11:20:58 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=EC=8B=9C=EA=B0=84=20=EC=B4=9D?= =?UTF-8?q?=EB=9F=89=EC=A0=9C=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=80=EA=B2=BD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/page/TimerPage/hooks/useTimeBasedTimer.ts | 266 ++++++++++++------ 1 file changed, 183 insertions(+), 83 deletions(-) diff --git a/src/page/TimerPage/hooks/useTimeBasedTimer.ts b/src/page/TimerPage/hooks/useTimeBasedTimer.ts index f06a64e..0bc8394 100644 --- a/src/page/TimerPage/hooks/useTimeBasedTimer.ts +++ b/src/page/TimerPage/hooks/useTimeBasedTimer.ts @@ -7,22 +7,16 @@ import { SetStateAction, } from 'react'; -interface UseTimeBasedTimerProps { - initIsSpeakingTimer?: boolean; // 처음에 발언 당 시간을 추가할지 여부 -} - /** * 토론에서 사용하는 커스텀 타이머 훅 * - 전체시간, 전체시간 + 발언 당 시간(2가지) 모드 지원 */ -export function useTimeBasedTimer({ - initIsSpeakingTimer = false, -}: UseTimeBasedTimerProps) { +export function useTimeBasedTimer() { // 전체 남은 시간 (null이면 타이머 미사용) const [totalTimer, setTotalTimer] = useState(null); // 발언당 시간 타이머(=각 phase별 제한시간, 모드 전환 가능) - const [isSpeakingTimer, setIsSpeakingTimer] = useState(initIsSpeakingTimer); const [speakingTimer, setSpeakingTimer] = useState(null); + const isSpeakingTimerAvailable = speakingTimer !== null; // 기본(초기) 시간값 (reset 등에서 참조) const [defaultTime, setDefaultTime] = useState<{ defaultTotalTimer: number | null; @@ -42,6 +36,56 @@ export function useTimeBasedTimer({ savedSpeakingTimer: number | null; }>({ savedTotalTimer: 0, savedSpeakingTimer: null }); + // 실제 시간 계산용 레퍼런스 + const targetTimeRef = useRef(null); + const speakingTargetTimeRef = useRef(null); + + /** + * 타이머 시작을 위해 사용하는 저수준 함수 + */ + const setTimerInterval = useCallback(() => { + // isRunning 상태를 true로 바꿔주고 인터벌 처리 + setIsRunning(true); + + // 목표 시각에 기반하여 타이머 계산 + intervalRef.current = setInterval(() => { + // 목표 시각 레퍼런스의 유효성 확인 + if (targetTimeRef.current === null) { + return; + } + + // 현재 시각 확인 + const now = Date.now(); + + // 목표 시각까지 얼마나 더 필요한지, 남은 시간을 초 단위로 계산 + const remainingTotal = targetTimeRef.current - now; + const remainingSeconds = Math.max(0, Math.ceil(remainingTotal / 1000)); + + // 계산한 남은 시간을 타이머에 반영 + setTotalTimer(remainingSeconds); + setSavedTime((prev) => { + return { ...prev, savedTotalTimer: remainingSeconds }; + }); + + // 1회당 발언 시간 타이머도 사용하고 있을 경우, 마찬가지로 남은 시간 계산 + if (isSpeakingTimerAvailable) { + if (speakingTargetTimeRef.current === null) { + return; + } + + const remainingSpeaking = speakingTargetTimeRef.current - now; + const remainingSpeakingSeconds = Math.max( + 0, + Math.ceil(remainingSpeaking / 1000), + ); + setSpeakingTimer(remainingSpeakingSeconds); + setSavedTime((prev) => { + return { ...prev, savedSpeakingTimer: remainingSpeakingSeconds }; + }); + } + }, 200); + }, [isSpeakingTimerAvailable]); + /** * 타이머 카운트다운 시작 * - 이미 실행 중이면 무시 @@ -49,30 +93,29 @@ export function useTimeBasedTimer({ * - 1초마다 totalTimer, speakingTimer(필요시) 감소 */ const startTimer = useCallback(() => { - if (intervalRef.current !== null) return; - if (!isDone) { - intervalRef.current = setInterval(() => { - // 리셋용 현재값 저장(초 단위로 갱신) - setSavedTime((prev) => { - return { ...prev, savedTotalTimer: totalTimer }; - }); - // 전체 타이머 감소 - setTotalTimer((prev) => - prev === null ? null : prev - 1 >= 0 ? prev - 1 : 0, - ); - // 발언 타이머 사용중이면 감소 - if (isSpeakingTimer) { - setSavedTime((prev) => { - return { ...prev, savedSpeakingTimer: speakingTimer }; - }); - setSpeakingTimer((prev) => - prev === null ? null : prev - 1 >= 0 ? prev - 1 : 0, - ); - } - }, 1000); - setIsRunning(true); + if (intervalRef.current !== null || totalTimer === null || isDone) { + return; } - }, [isDone, isSpeakingTimer, totalTimer, speakingTimer]); + + // 목표 시각을 실제 시각 기반으로 계산 + // 예를 들어, 현재 시각이 오후 13시 00분 30초인데, 1회당 발언 시간이 30초라면, + // 1회당 발언 시간이 모두 끝나는 시간은 13시 01분 00초이므로, + // 해당 시간을 목표 시간으로 두는 식임 + const startTime = Date.now(); + targetTimeRef.current = startTime + totalTimer * 1000; + if (isSpeakingTimerAvailable) { + speakingTargetTimeRef.current = startTime + speakingTimer * 1000; + } + + // 타이머 인터벌 시작 + setTimerInterval(); + }, [ + isDone, + isSpeakingTimerAvailable, + setTimerInterval, + speakingTimer, + totalTimer, + ]); /** * 타이머 일시정지 @@ -89,57 +132,116 @@ export function useTimeBasedTimer({ /** * 최근 저장된 시간(savedTime)으로 복원 */ - const resetCurrentTimer = useCallback(() => { - pauseTimer(); - setIsDone(false); - setTotalTimer(savedTime.savedTotalTimer); - if ( - isSpeakingTimer && - defaultTime.defaultSpeakingTimer !== null && - totalTimer !== null - ) { - setSpeakingTimer(savedTime.savedSpeakingTimer); - } - }, [ - isSpeakingTimer, - defaultTime.defaultSpeakingTimer, - savedTime.savedTotalTimer, - savedTime.savedSpeakingTimer, - totalTimer, - pauseTimer, - ]); + const resetCurrentTimer = useCallback( + (isOpponentDone: boolean) => { + // 초기화를 위해 타이머 정지 + pauseTimer(); + + // 타이머가 초기화되었으니 이제부터는 당연히 다시 동작 가능하기 때문에, + // isDone을 false로 설정 + setIsDone(false); + + // 전체 발언 시간 복원 + setTotalTimer(savedTime.savedTotalTimer); + + // 1회당 발언 시간 사용하는지 여부와 유효성 확인 + if ( + !isSpeakingTimerAvailable || + defaultTime.defaultSpeakingTimer === null || + totalTimer === null + ) { + return; + } + + // 상대편 발언 종료 여부에 따라 1회당 발언 시간 다르게 계산 + if (isOpponentDone) { + setSpeakingTimer(savedTime.savedTotalTimer); + } else { + setSpeakingTimer(savedTime.savedSpeakingTimer); + } + }, + [ + isSpeakingTimerAvailable, + savedTime.savedSpeakingTimer, + savedTime.savedTotalTimer, + defaultTime.defaultSpeakingTimer, + totalTimer, + pauseTimer, + ], + ); /** * 발언자 전환 시 타이머 리셋/초기화 * - 발언 타이머 사용중이면 default값(또는 totalTimer 이하)로 재설정 * - 전체 타이머는 초기값(defaultTotalTimer)로 리셋 */ - const resetTimerForNextPhase = useCallback(() => { - pauseTimer(); - if ( - isSpeakingTimer && - defaultTime.defaultSpeakingTimer !== null && - totalTimer !== null - ) { - setSpeakingTimer( - defaultTime.defaultSpeakingTimer < totalTimer - ? defaultTime.defaultSpeakingTimer - : totalTimer, - ); - if (totalTimer > 0) { - setIsDone(false); + const resetTimerForNextPhase = useCallback( + (isOpponentDone: boolean): number => { + if ( + isSpeakingTimerAvailable && + defaultTime.defaultSpeakingTimer !== null && + totalTimer !== null + ) { + // # 1회당 발언 시간을 사용할 경우 + + // 다음 발언 시간 계산 + // - 상대방 시간 모두 소진 시, 남아있는 전체 발언 시간을 모두 1회당 발언 시간으로 사용 + // - 상대방 시간이 남았을 때, 1회당 발언 시간과 남은 전체 발언 시간 중 더 작은 것을 사용 + const nextSpeakingTime = isOpponentDone + ? totalTimer + : Math.min(totalTimer, defaultTime.defaultSpeakingTimer); + + // 계산한 시간을 1회당 발언 시간으로 설정 + setSpeakingTimer(nextSpeakingTime); + return nextSpeakingTime; + } else { + // # 1회당 발언 시간을 사용하지 않을 경우 + + // 전체 발언 시간 타이머는 초기값으로 리셋 + if (totalTimer === 0 || totalTimer === null) { + return 0; + } + return totalTimer; } - return; - } - if (totalTimer === 0) return; - setTotalTimer(defaultTime.defaultTotalTimer); - }, [ - defaultTime.defaultSpeakingTimer, - defaultTime.defaultTotalTimer, - isSpeakingTimer, - totalTimer, - pauseTimer, - ]); + }, + [defaultTime.defaultSpeakingTimer, isSpeakingTimerAvailable, totalTimer], + ); + + /** + * 발언자 전환 시 타이머 리셋/초기화 후 즉시 실행 + * - 발언 타이머 사용중이면 default값(또는 totalTimer 이하)로 재설정 + * - 전체 타이머는 초기값(defaultTotalTimer)로 리셋 + */ + const resetAndStartTimer = useCallback( + (isOpponentDone: boolean) => { + console.log(`# resetAndStartTimer 호출`); + const newTime = resetTimerForNextPhase(isOpponentDone); + + if (intervalRef.current !== null || totalTimer === null || isDone) { + return; + } + + // 목표 시각을 실제 시각 기반으로 계산 + // 예를 들어, 현재 시각이 오후 13시 00분 30초인데, 1회당 발언 시간이 30초라면, + // 1회당 발언 시간이 모두 끝나는 시간은 13시 01분 00초이므로, + // 해당 시간을 목표 시간으로 두는 식임 + const startTime = Date.now(); + targetTimeRef.current = startTime + totalTimer * 1000; + if (isSpeakingTimerAvailable) { + speakingTargetTimeRef.current = startTime + newTime * 1000; + } + + // 타이머 인터벌 시작 + setTimerInterval(); + }, + [ + resetTimerForNextPhase, + setTimerInterval, + isDone, + isSpeakingTimerAvailable, + totalTimer, + ], + ); /** * 외부에서 전체/발언 타이머를 지정값으로 재설정 @@ -162,11 +264,9 @@ export function useTimeBasedTimer({ pauseTimer(); setDefaultTime({ defaultTotalTimer: 0, defaultSpeakingTimer: null }); setTotalTimer(null); - setIsSpeakingTimer(initIsSpeakingTimer); setSpeakingTimer(null); setIsDone(false); intervalRef.current = null; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [pauseTimer]); useEffect(() => () => pauseTimer(), [pauseTimer]); @@ -177,15 +277,15 @@ export function useTimeBasedTimer({ isRunning, isDone, defaultTime, - isSpeakingTimer, + isSpeakingTimerAvailable, startTimer, pauseTimer, resetTimerForNextPhase, + resetAndStartTimer, resetCurrentTimer, setTimers, setSavedTime, setDefaultTime, - setIsSpeakingTimer, setIsDone, clearTimer, }; @@ -200,11 +300,12 @@ export interface TimeBasedTimerLogics { defaultTotalTimer: number | null; defaultSpeakingTimer: number | null; }; - isSpeakingTimer: boolean; + isSpeakingTimerAvailable: boolean; startTimer: () => void; pauseTimer: () => void; - resetTimerForNextPhase: () => void; - resetCurrentTimer: () => void; + resetTimerForNextPhase: (isOpponentDone: boolean) => number; + resetAndStartTimer: (isOpponentDone: boolean) => void; + resetCurrentTimer: (isOpponentDone: boolean) => void; setTimers: (total: number | null, speaking?: number | null) => void; setSavedTime: Dispatch< SetStateAction<{ @@ -218,7 +319,6 @@ export interface TimeBasedTimerLogics { defaultSpeakingTimer: number | null; }> >; - setIsSpeakingTimer: Dispatch>; setIsDone: Dispatch>; clearTimer: () => void; } From 8d21befcc10c8df02ce78d27e34066fe80fb2d64 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Tue, 5 Aug 2025 13:40:04 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=B6=94=EA=B0=80=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/page/TimerPage/TimerPage.tsx | 3 +- .../TimerPage/hooks/useTimerBackground.ts | 97 +++++++++++++++++++ src/page/TimerPage/hooks/useTimerPageState.ts | 22 ++++- src/type/type.ts | 9 ++ 4 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 src/page/TimerPage/hooks/useTimerBackground.ts diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index cf4c61f..9d5700d 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -4,7 +4,7 @@ import HeaderTableInfo from '../../components/HeaderTableInfo/HeaderTableInfo'; import HeaderTitle from '../../components/HeaderTitle/HeaderTitle'; import IconButton from '../../components/IconButton/IconButton'; import { IoHelpCircle } from 'react-icons/io5'; -import { bgColorMap, useTimerPageState } from './hooks/useTimerPageState'; +import { useTimerPageState } from './hooks/useTimerPageState'; import { useTimerHotkey } from './hooks/useTimerHotkey'; import RoundControlRow from './components/RoundControlRow'; import TimerView from './components/TimerView'; @@ -13,6 +13,7 @@ import { FirstUseToolTipModal } from './components/FirstUseToolTipModal'; import { isUUID } from '../../util/type_guard'; import LoadingIndicator from '../../components/async/LoadingIndicator'; import ErrorIndicator from '../../components/async/ErrorIndicator'; +import { bgColorMap } from '../../type/type'; export default function TimerPage() { const { id } = useParams(); diff --git a/src/page/TimerPage/hooks/useTimerBackground.ts b/src/page/TimerPage/hooks/useTimerBackground.ts new file mode 100644 index 0000000..8b52baa --- /dev/null +++ b/src/page/TimerPage/hooks/useTimerBackground.ts @@ -0,0 +1,97 @@ +// src/page/TimerPage/hooks/useTimerBackgroundState.ts + +import { useEffect, useState } from 'react'; +import { TimeBasedTimerLogics } from './useTimeBasedTimer'; +import { NormalTimerLogics } from './useNormalTimer'; +import { + DebateTableData, + TimeBasedStance, + TimerBGState, +} from '../../../type/type'; + +const TIME_THRESHOLDS = { + WARNING_MAX: 30, + DANGER_MAX: 10, + DANGER_MIN: 0, +} as const; + +/** + * 타이머 상태(색상)를 계산한다. + * isRunning이 false면 항상 'default' 반환. + */ +function getTimerStatusByTime( + time: number | null, + isRunning: boolean, +): TimerBGState { + if (!isRunning) return 'default'; + if (typeof time !== 'number') return 'default'; + if (time > TIME_THRESHOLDS.DANGER_MAX && time <= TIME_THRESHOLDS.WARNING_MAX) + return 'warning'; + if (time >= TIME_THRESHOLDS.DANGER_MIN && time <= TIME_THRESHOLDS.DANGER_MAX) + return 'danger'; + if (time < TIME_THRESHOLDS.DANGER_MIN) return 'expired'; + return 'default'; +} +interface UseTimerBackgroundProps { + timer1: TimeBasedTimerLogics; + timer2: TimeBasedTimerLogics; + normalTimer: NormalTimerLogics; + prosConsSelected: TimeBasedStance; + data: DebateTableData | null; + index: number; +} + +export function useTimerBackground({ + timer1, + timer2, + normalTimer, + prosConsSelected, + data, + index, +}: UseTimerBackgroundProps) { + const [bg, setBg] = useState('default'); + + useEffect(() => { + const boxType = data?.table[index].boxType; + + if (boxType === 'NORMAL') { + setBg(getTimerStatusByTime(normalTimer.timer, normalTimer.isRunning)); + } else if (boxType === 'TIME_BASED') { + if (prosConsSelected === 'PROS') { + const timer = timer1; + setBg( + getTimerStatusByTime( + timer.speakingTimer ?? timer.totalTimer, + timer.isRunning, + ), + ); + } else { + const timer = timer2; + setBg( + getTimerStatusByTime( + timer.speakingTimer ?? timer.totalTimer, + timer.isRunning, + ), + ); + } + } else { + setBg('default'); + } + }, [ + normalTimer.isRunning, + normalTimer.timer, + timer1.isRunning, + timer1.speakingTimer, + timer1.totalTimer, + timer2.isRunning, + timer2.speakingTimer, + timer2.totalTimer, + prosConsSelected, + data, + index, + timer1, + timer2, + ]); + + return { bg, setBg }; +} diff --git a/src/page/TimerPage/hooks/useTimerPageState.ts b/src/page/TimerPage/hooks/useTimerPageState.ts index 5513302..bf394b7 100644 --- a/src/page/TimerPage/hooks/useTimerPageState.ts +++ b/src/page/TimerPage/hooks/useTimerPageState.ts @@ -10,7 +10,11 @@ import { import { TimeBasedTimerLogics, useTimeBasedTimer } from './useTimeBasedTimer'; import { NormalTimerLogics, useNormalTimer } from './useNormalTimer'; import { useBellSound } from './useBellSound'; -import { DebateTableData, TimeBasedStance } from '../../../type/type'; +import { + DebateTableData, + TimeBasedStance, + TimerBGState, +} from '../../../type/type'; import repository from '../../../repositories/IPCDebateTableRepository'; import { UUID } from 'crypto'; import useAsyncRequest from '../../../repositories/useAsyncRequest'; @@ -23,12 +27,12 @@ export const bgColorMap: Record = { danger: 'bg-brand-sub3', // 10초 이하 expired: 'bg-neutral-700', // 0초 이하 }; +import { useTimerBackground } from './useTimerBackground'; /** * 타이머 페이지의 상태(타이머, 라운드, 벨 등) 전반을 관리하는 커스텀 훅 */ export function useTimerPageState(tableId: UUID) { - const [bg, setBg] = useState('default'); const [data, setData] = useState(null); const { data: patchedData, @@ -71,6 +75,16 @@ export function useTimerPageState(tableId: UUID) { isFinishBell: data?.info.finishBell, }); + // 배경 색상 관련 훅 + const { bg, setBg } = useTimerBackground({ + timer1, + timer2, + normalTimer, + prosConsSelected, + data, + index, + }); + /** * 라운드 이동 (이전/다음) */ @@ -333,8 +347,8 @@ export interface TimerPageLogics { warningBellRef: RefObject; finishBellRef: RefObject; data: DebateTableData | null; - bg: TimerState; - setBg: Dispatch>; + bg: TimerBGState; + setBg: Dispatch>; isAdditionalTimerAvailable: boolean; index: number; setIndex: Dispatch>; diff --git a/src/type/type.ts b/src/type/type.ts index 45dbda7..2419a83 100644 --- a/src/type/type.ts +++ b/src/type/type.ts @@ -54,3 +54,12 @@ export interface DebateTableData { info: DebateInfo; table: TimeBoxInfo[]; } + +// ===== 배경 색상 상태 타입 및 컬러 맵 정의 ===== +export type TimerBGState = 'default' | 'warning' | 'danger' | 'expired'; +export const bgColorMap: Record = { + default: '', + warning: 'bg-brand-main', // 30초~11초 구간 + danger: 'bg-brand-sub3', // 10초 이하 + expired: 'bg-neutral-700', // 0초 이하 +}; From 0e7cd264b39a92b98f1bc8ecf10e3a82f10f702a Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Tue, 5 Aug 2025 13:48:57 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=B4=EA=B0=95=20=EB=B0=8F=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/page/TimerPage/components/NormalTimer.tsx | 2 +- src/page/TimerPage/components/TimerView.tsx | 4 +- src/page/TimerPage/hooks/useNormalTimer.ts | 2 +- src/page/TimerPage/hooks/useTimeBasedTimer.ts | 34 +--- src/page/TimerPage/hooks/useTimerHotkey.ts | 24 ++- src/page/TimerPage/hooks/useTimerPageState.ts | 183 ++++++------------ 6 files changed, 89 insertions(+), 160 deletions(-) diff --git a/src/page/TimerPage/components/NormalTimer.tsx b/src/page/TimerPage/components/NormalTimer.tsx index 06dd674..6ad8f3f 100644 --- a/src/page/TimerPage/components/NormalTimer.tsx +++ b/src/page/TimerPage/components/NormalTimer.tsx @@ -90,7 +90,7 @@ export default function NormalTimer({ {/* Speaker's number, if necessary */}
- {item.stance !== 'NEUTRAL' && isAdditionalTimerOn && ( + {item.stance !== 'NEUTRAL' && !isAdditionalTimerOn && ( <>

diff --git a/src/page/TimerPage/components/TimerView.tsx b/src/page/TimerPage/components/TimerView.tsx index 07e1dcc..2d6cd15 100644 --- a/src/page/TimerPage/components/TimerView.tsx +++ b/src/page/TimerPage/components/TimerView.tsx @@ -61,7 +61,7 @@ export default function TimerView({ state }: { state: TimerPageLogics }) { isRunning: timer1.isRunning, startTimer: timer1.startTimer, pauseTimer: timer1.pauseTimer, - resetCurrentTimer: timer1.resetCurrentTimer, + resetCurrentTimer: () => timer1.resetCurrentTimer(timer2.isDone), }} isSelected={prosConsSelected === 'PROS'} onActivate={() => handleActivateTeam('PROS')} @@ -76,7 +76,7 @@ export default function TimerView({ state }: { state: TimerPageLogics }) { isRunning: timer2.isRunning, startTimer: timer2.startTimer, pauseTimer: timer2.pauseTimer, - resetCurrentTimer: timer2.resetCurrentTimer, + resetCurrentTimer: () => timer2.resetCurrentTimer(timer1.isDone), }} isSelected={prosConsSelected === 'CONS'} onActivate={() => handleActivateTeam('CONS')} diff --git a/src/page/TimerPage/hooks/useNormalTimer.ts b/src/page/TimerPage/hooks/useNormalTimer.ts index 048a38c..d8e21ca 100644 --- a/src/page/TimerPage/hooks/useNormalTimer.ts +++ b/src/page/TimerPage/hooks/useNormalTimer.ts @@ -11,7 +11,7 @@ import { * "일반 타이머" 기능을 제공하는 커스텀 훅 * - 한 명(한 팀)만 시간을 쓰는 단일 타이머 상황에서 사용 */ -export function useNormalTimer() { +export function useNormalTimer(): NormalTimerLogics { // 타이머에 표시할 '남은 시간'(초) const [timer, setTimer] = useState(null); const intervalRef = useRef(null); diff --git a/src/page/TimerPage/hooks/useTimeBasedTimer.ts b/src/page/TimerPage/hooks/useTimeBasedTimer.ts index 0bc8394..d995754 100644 --- a/src/page/TimerPage/hooks/useTimeBasedTimer.ts +++ b/src/page/TimerPage/hooks/useTimeBasedTimer.ts @@ -11,12 +11,14 @@ import { * 토론에서 사용하는 커스텀 타이머 훅 * - 전체시간, 전체시간 + 발언 당 시간(2가지) 모드 지원 */ -export function useTimeBasedTimer() { +export function useTimeBasedTimer(): TimeBasedTimerLogics { // 전체 남은 시간 (null이면 타이머 미사용) const [totalTimer, setTotalTimer] = useState(null); + // 발언당 시간 타이머(=각 phase별 제한시간, 모드 전환 가능) const [speakingTimer, setSpeakingTimer] = useState(null); const isSpeakingTimerAvailable = speakingTimer !== null; + // 기본(초기) 시간값 (reset 등에서 참조) const [defaultTime, setDefaultTime] = useState<{ defaultTotalTimer: number | null; @@ -25,17 +27,13 @@ export function useTimeBasedTimer() { // 현재 타이머 동작중 여부 const [isRunning, setIsRunning] = useState(false); + // setInterval() 저장용 ref const intervalRef = useRef(null); + // 타이머가 0이 되면 true (완료 상태) const [isDone, setIsDone] = useState(false); - // 리셋/롤백용: 최근 사용된(변경 직전) 값 저장 - const [savedTime, setSavedTime] = useState<{ - savedTotalTimer: number | null; - savedSpeakingTimer: number | null; - }>({ savedTotalTimer: 0, savedSpeakingTimer: null }); - // 실제 시간 계산용 레퍼런스 const targetTimeRef = useRef(null); const speakingTargetTimeRef = useRef(null); @@ -63,9 +61,6 @@ export function useTimeBasedTimer() { // 계산한 남은 시간을 타이머에 반영 setTotalTimer(remainingSeconds); - setSavedTime((prev) => { - return { ...prev, savedTotalTimer: remainingSeconds }; - }); // 1회당 발언 시간 타이머도 사용하고 있을 경우, 마찬가지로 남은 시간 계산 if (isSpeakingTimerAvailable) { @@ -79,9 +74,6 @@ export function useTimeBasedTimer() { Math.ceil(remainingSpeaking / 1000), ); setSpeakingTimer(remainingSpeakingSeconds); - setSavedTime((prev) => { - return { ...prev, savedSpeakingTimer: remainingSpeakingSeconds }; - }); } }, 200); }, [isSpeakingTimerAvailable]); @@ -142,7 +134,7 @@ export function useTimeBasedTimer() { setIsDone(false); // 전체 발언 시간 복원 - setTotalTimer(savedTime.savedTotalTimer); + setTotalTimer(defaultTime.defaultTotalTimer); // 1회당 발언 시간 사용하는지 여부와 유효성 확인 if ( @@ -155,16 +147,15 @@ export function useTimeBasedTimer() { // 상대편 발언 종료 여부에 따라 1회당 발언 시간 다르게 계산 if (isOpponentDone) { - setSpeakingTimer(savedTime.savedTotalTimer); + setSpeakingTimer(defaultTime.defaultTotalTimer); } else { - setSpeakingTimer(savedTime.savedSpeakingTimer); + setSpeakingTimer(defaultTime.defaultSpeakingTimer); } }, [ isSpeakingTimerAvailable, - savedTime.savedSpeakingTimer, - savedTime.savedTotalTimer, defaultTime.defaultSpeakingTimer, + defaultTime.defaultTotalTimer, totalTimer, pauseTimer, ], @@ -284,7 +275,6 @@ export function useTimeBasedTimer() { resetAndStartTimer, resetCurrentTimer, setTimers, - setSavedTime, setDefaultTime, setIsDone, clearTimer, @@ -307,12 +297,6 @@ export interface TimeBasedTimerLogics { resetAndStartTimer: (isOpponentDone: boolean) => void; resetCurrentTimer: (isOpponentDone: boolean) => void; setTimers: (total: number | null, speaking?: number | null) => void; - setSavedTime: Dispatch< - SetStateAction<{ - savedTotalTimer: number | null; - savedSpeakingTimer: number | null; - }> - >; setDefaultTime: Dispatch< SetStateAction<{ defaultTotalTimer: number | null; diff --git a/src/page/TimerPage/hooks/useTimerHotkey.ts b/src/page/TimerPage/hooks/useTimerHotkey.ts index 1ee6bd5..cf06690 100644 --- a/src/page/TimerPage/hooks/useTimerHotkey.ts +++ b/src/page/TimerPage/hooks/useTimerHotkey.ts @@ -6,7 +6,7 @@ import { TimerPageLogics } from './useTimerPageState'; * - Space: 타이머 시작/일시정지 * - KeyR: 타이머 리셋 * - KeyA/KeyL: 각각 찬/반 진영 타이머 활성화 - * - Enter/NumpadEnter: 진영 전환 + * - Enter, NumpadEnter: 진영 전환 */ export function useTimerHotkey(state: TimerPageLogics) { const { @@ -81,24 +81,30 @@ export function useTimerHotkey(state: TimerPageLogics) { normalTimer.resetTimer(); } else { if (prosConsSelected === 'PROS') { - timer1.resetCurrentTimer(); + timer1.resetCurrentTimer(timer2.isDone); } else { - timer2.resetCurrentTimer(); + timer2.resetCurrentTimer(timer1.isDone); } } break; case 'KeyA': // 찬성 진영 선택 및 반대 타이머 정지 - if (!timer1.isDone) { - setProsConsSelected('PROS'); - if (timer2.isRunning) timer2.pauseTimer(); + if (prosConsSelected === 'CONS') { + if (timer1.isDone) { + setProsConsSelected('PROS'); + } else { + switchCamp(); + } } break; case 'KeyL': // 반대 진영 선택 및 찬성 타이머 정지 - if (!timer2.isDone) { - setProsConsSelected('CONS'); - if (timer1.isRunning) timer1.pauseTimer(); + if (prosConsSelected === 'PROS') { + if (timer1.isDone) { + setProsConsSelected('CONS'); + } else { + switchCamp(); + } } break; case 'Enter': diff --git a/src/page/TimerPage/hooks/useTimerPageState.ts b/src/page/TimerPage/hooks/useTimerPageState.ts index bf394b7..9cf2acc 100644 --- a/src/page/TimerPage/hooks/useTimerPageState.ts +++ b/src/page/TimerPage/hooks/useTimerPageState.ts @@ -18,15 +18,6 @@ import { import repository from '../../../repositories/IPCDebateTableRepository'; import { UUID } from 'crypto'; import useAsyncRequest from '../../../repositories/useAsyncRequest'; - -// ===== 배경 색상 상태 타입 및 컬러 맵 정의 ===== -export type TimerState = 'default' | 'warning' | 'danger' | 'expired'; -export const bgColorMap: Record = { - default: '', - warning: 'bg-brand-main', // 30초~11초 구간 - danger: 'bg-brand-sub3', // 10초 이하 - expired: 'bg-neutral-700', // 0초 이하 -}; import { useTimerBackground } from './useTimerBackground'; /** @@ -44,12 +35,7 @@ export function useTimerPageState(tableId: UUID) { // 추가 타이머가 가능한지 여부 (예: 사전에 설정한 "작전 시간"이 있으면 false) const isAdditionalTimerAvailable = useMemo(() => { if (data) { - return data.table.every((value) => { - if (value.speechType !== '작전 시간') { - return true; - } - return false; - }); + return !data.table.some((value) => value.speechType === '작전 시간'); } return true; }, [data]); @@ -58,8 +44,8 @@ export function useTimerPageState(tableId: UUID) { const [index, setIndex] = useState(0); // 자유토론 타이머, 일반 타이머 상태 관리 커스텀 훅 - const timer1 = useTimeBasedTimer({}); - const timer2 = useTimeBasedTimer({}); + const timer1 = useTimeBasedTimer(); + const timer2 = useTimeBasedTimer(); const normalTimer = useNormalTimer(); // 현재 발언자('PROS'/'CONS') @@ -103,49 +89,66 @@ export function useTimerPageState(tableId: UUID) { [index, data], ); - /** - * 특정 진영(팀)을 활성화하는 함수 - * - 사용자가 좌/우 타이머 영역을 직접 클릭할 때 사용 - * - 현재 진영이 아닌 타이머를 클릭한 경우에만 동작 - */ - const handleActivateTeam = useCallback( - (team: TimeBasedStance) => { - const isPros = team === 'PROS'; - const currentTimer = isPros ? timer1 : timer2; - const otherTimer = isPros ? timer2 : timer1; - if (currentTimer.isDone) return; - - // 반대편에서 클릭했을 때만 - if (prosConsSelected !== team) { - if (otherTimer.isRunning) { - otherTimer.pauseTimer(); - currentTimer.startTimer(); - setProsConsSelected(team); - } else { - otherTimer.pauseTimer(); - setProsConsSelected(team); - } - } - }, - [prosConsSelected, timer1, timer2], - ); - /** * 발언 진영 전환(ENTER 키/버튼) * - pros → cons, cons → pros로 타이머/상태 전환 */ const switchCamp = useCallback(() => { + // 1. 현재 팀과 다음 팀의 정보 설정 const currentTimer = prosConsSelected === 'PROS' ? timer1 : timer2; const nextTimer = prosConsSelected === 'PROS' ? timer2 : timer1; const nextTeam = prosConsSelected === 'PROS' ? 'CONS' : 'PROS'; - if (nextTimer.isDone) return; - currentTimer.pauseTimer(); - if (!nextTimer.isDone && currentTimer.isRunning) { - nextTimer.startTimer(); + + // 2. 상대 팀이 시간을 모두 소진했을 경우, 차례를 넘기지 않고 반환 + if (nextTimer.isDone) { + return; } + + // 3. 현재 팀의 발언이 종료되었는지 확인 + const isOpponentDone = + currentTimer.totalTimer !== null && currentTimer.totalTimer <= 0; + + // 4. 현재 타이머를 멈추기 전, 실행 중이었는지를 저장 + const wasRunning = currentTimer.isRunning; + + // 5. 이제 현재 타이머를 정지하고... + currentTimer.pauseTimer(); + + // 6. 발언권을 다음 팀에게 넘김 (현재 발언권 가진 팀을 다음 팀으로 설정) setProsConsSelected(nextTeam); + + if (wasRunning) { + // 7-1. 만약 타이머가 실행 중이었다면, 다음 타이머 초기화 후 즉시 시작 + nextTimer.resetAndStartTimer(isOpponentDone); + } else { + // 7-1. 만약 타이머가 멈춰 있었다면, 다음 타이머 초기화만 진행 + nextTimer.resetTimerForNextPhase(isOpponentDone); + } }, [prosConsSelected, timer1, timer2]); + /** + * 특정 진영(팀)을 활성화하는 함수 + * - 사용자가 좌/우 타이머 영역을 직접 클릭할 때 사용 + * - 현재 진영이 아닌 타이머를 클릭한 경우에만 동작 + */ + const handleActivateTeam = useCallback( + (team: TimeBasedStance) => { + const clickedTimerStance = team === 'PROS' ? 'PROS' : 'CONS'; + const clickedTimer = clickedTimerStance === 'PROS' ? timer1 : timer2; + + // 클릭한 타이머가 현재 타이머와 동일한 타이머라면, 바로 반환 + if (prosConsSelected === clickedTimerStance) return; + + // 아니라면, 타이머 변경 + if (clickedTimer.isDone) { + setProsConsSelected(clickedTimerStance); + } else { + switchCamp(); + } + }, + [prosConsSelected, switchCamp, timer1, timer2], + ); + /** * 데이터를 IPC 저장소에서 불러옴 */ @@ -163,63 +166,6 @@ export function useTimerPageState(tableId: UUID) { getData(); }, [getTable, tableId]); - /** - * 현재 라운드/타이머 상태 변화에 따라 배경 상태(bg) 자동 변경 - */ - useEffect(() => { - // 각 타이머별 상태에 따라 warning/danger/expired 판정 - const getBgStatus = () => { - const boxType = data?.table[index].boxType; - - // 발언 타이머 기준 상태 산정 함수 - const getTimerStatus = ( - speakingTimer: number | null, - totalTimer: number | null, - ) => { - const activeTimer = speakingTimer !== null ? speakingTimer : totalTimer; - if (activeTimer !== null) { - if (activeTimer > 10 && activeTimer <= 30) return 'warning'; - if (activeTimer >= 0 && activeTimer <= 10) return 'danger'; - } - return 'default'; - }; - - if (boxType === 'NORMAL') { - if (!normalTimer.isRunning) return 'default'; - if (normalTimer.timer !== null) { - if (normalTimer.timer > 10 && normalTimer.timer <= 30) - return 'warning'; - if (normalTimer.timer >= 0 && normalTimer.timer <= 10) - return 'danger'; - if (normalTimer.timer < 0) return 'expired'; - return 'default'; - } - } - if (boxType === 'TIME_BASED') { - if (prosConsSelected === 'PROS' && timer1.isRunning) { - return getTimerStatus(timer1.speakingTimer, timer1.totalTimer); - } - if (prosConsSelected === 'CONS' && timer2.isRunning) { - return getTimerStatus(timer2.speakingTimer, timer2.totalTimer); - } - } - return 'default'; - }; - setBg(getBgStatus()); - }, [ - normalTimer.isRunning, - normalTimer.timer, - timer1.isRunning, - timer1.totalTimer, - timer1.speakingTimer, - timer2.isRunning, - timer2.totalTimer, - timer2.speakingTimer, - prosConsSelected, - index, - data, - ]); - /** * 라운드 이동/초기 진입 시 타이머 상태 초기화 및 셋업 */ @@ -244,12 +190,7 @@ export function useTimerPageState(tableId: UUID) { const defaultSpeakingTimer = currentBox.timePerSpeaking; [timer1, timer2].forEach((timer) => { timer.setDefaultTime({ defaultTotalTimer, defaultSpeakingTimer }); - timer.setSavedTime({ - savedTotalTimer: defaultTotalTimer, - savedSpeakingTimer: defaultSpeakingTimer, - }); timer.setTimers(defaultTotalTimer, defaultSpeakingTimer); - timer.setIsSpeakingTimer(true); timer.setIsDone(false); }); } @@ -266,15 +207,13 @@ export function useTimerPageState(tableId: UUID) { ]); /** - * 진영 전환 시, 상대 타이머를 발언 구간에 맞게 초기화 + * 진영 전환 시, 상대 타이머가 작동 가능하면 isDone을 false로 변경 */ useEffect(() => { - if (prosConsSelected === 'CONS') { - if (timer1.speakingTimer === null) return; - timer1.resetTimerForNextPhase(); - } else if (prosConsSelected === 'PROS') { - if (timer2.speakingTimer === null) return; - timer2.resetTimerForNextPhase(); + const isPros = prosConsSelected === 'PROS'; + const opponentTimer = isPros ? timer2 : timer1; + if (opponentTimer.totalTimer !== null && opponentTimer.totalTimer > 0) { + opponentTimer.setIsDone(false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [prosConsSelected]); @@ -283,11 +222,11 @@ export function useTimerPageState(tableId: UUID) { * 각 타이머가 종료 시 자동으로 타이머 일시정지 */ useEffect(() => { - if (timer1.speakingTimer === 0 || timer1.totalTimer === 0) { - timer1.pauseTimer(); - } else if (timer2.speakingTimer === 0 || timer2.totalTimer === 0) { - timer2.pauseTimer(); - } + [timer1, timer2].forEach((timer) => { + if (timer.speakingTimer === 0 || timer.totalTimer === 0) { + timer.pauseTimer(); + } + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ timer1.speakingTimer, From bd1bc3a7f20897506f6f0d09ef3e92e56c344417 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Tue, 5 Aug 2025 13:49:11 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20Storybook=EC=9D=B4=20msw=EB=A5=BC=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EC=A7=80=20=EB=AA=BB=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .storybook/main.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.storybook/main.ts b/.storybook/main.ts index 11634b8..9379e51 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -16,5 +16,17 @@ const config: StorybookConfig = { core: { builder: '@storybook/builder-vite', }, + async viteFinal(config, { configType }) { + console.log(`# configType = ${configType}`); + + // Storybook의 실행 모드를 테스트 모드로 고정하여 + // msw가 잘 동작할 수 있게 준비 + config.define = { + ...config.define, + 'import.meta.env.MODE': JSON.stringify('test'), + }; + + return config; + }, }; export default config; From ab658558dd40fea4a527b857a9b0d23a737d626a Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Tue, 5 Aug 2025 13:49:16 +0900 Subject: [PATCH 7/7] =?UTF-8?q?chore:=20=EB=B2=84=EC=A0=84=20=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c13ff31..edb7fb1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "debate-timer-fe-win", "private": true, - "version": "1.0.1", + "version": "1.0.2", "homepage": "./", "main": "./dist/main/main.js", "scripts": {