From e1cc5df982acaaccfed0d943005a6edefbc1490a Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 27 Sep 2026 01:31:35 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EC=B2=AD=EC=A4=91=20=EC=9E=90?= =?UTF-8?q?=EC=9C=A0=ED=86=A0=EB=A1=A0=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=8B=9C=EA=B0=84=EC=9D=84=20=EB=B0=9C?= =?UTF-8?q?=EC=96=B8=20=EC=8B=9C=EA=B0=84=EC=97=90=EC=84=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=83=9D=ED=95=B4=20=EB=8F=99=EC=8B=9C=EC=97=90=20=EA=B0=90?= =?UTF-8?q?=EC=86=8C=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAY/STOP 등은 발언 시간만 갱신하여 발언 카운트다운에만 네트워크 지연 보정이 적용되고, 전체 카운트다운은 별도 목표 시각·인터벌로 돌아 초 경계가 어긋났다. 1회 발언 시간 모드에서는 전체 시간을 발언 시간 + 오프셋으로 파생한다. Co-Authored-By: Claude Opus 5.5 --- docs/live-share-timer-sync.md | 2 + .../useAudienceTimeBasedCountdown.test.ts | 48 +++++++++++ .../hooks/useAudienceTimeBasedCountdown.ts | 79 +++++++++++++++++-- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/docs/live-share-timer-sync.md b/docs/live-share-timer-sync.md index f65cfa85..e186acde 100644 --- a/docs/live-share-timer-sync.md +++ b/docs/live-share-timer-sync.md @@ -37,6 +37,8 @@ - 정지 상태, 정지 값에서 재개, 기준 시각이 미래이거나 2초 넘게 지난 경우는 보정하지 않습니다. 4. 사회자는 남은 시간을 **올림한 정수 초**로 보내므로 실행 중 스냅샷은 실제보다 최대 1초 큽니다. 실행 중 재동기화 차이가 1초 미만이면 기존 목표 시각과 새 목표 시각 중 **더 이른 쪽**을 택해, 5초 주기 SYNC마다 표시가 튀지 않게 합니다. +5. 자유토론(1회 발언 시간 사용)에서 `PLAY`/`STOP` 등은 발언 시간만 보내므로, 전체 시간을 따로 카운트다운하면 지연 보정이 발언 시간에만 적용되어 두 값의 초 경계가 어긋납니다. 사회자는 두 시간을 같이 시작·정지하므로, 청중은 `전체 − 발언` 차이를 오프셋으로 두고 **전체 시간 = 발언 시간 + 오프셋**으로 파생해 두 값이 같은 틱에 바뀌게 합니다. 오프셋은 설정 변경·`SYNC`·`RESET`·`TEAM_SWITCH`(새 팀)에서만 다시 계산합니다. (`useAudienceTimeBasedCountdown`) + - 보정하지 않는 구간: 사회자 → 서버 구간 지연, 1초 이상의 비정상 지연. - 청중 간 차이는 정수 초 표시 특성상 최대 1초까지 날 수 있습니다. diff --git a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts index 10990b4d..cbef8f30 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts @@ -127,6 +127,54 @@ describe('useAudienceTimeBasedCountdown', () => { expect(result.current.pros.currentSpeakingRemainingTime).toBe(27); }); + it('네트워크 지연이 있는 재생에서도 전체 시간과 현재 시간이 같은 순간에 감소한다', () => { + vi.setSystemTime(new Date('2026-09-27T00:00:00Z')); + const { result, rerender } = renderHook( + ({ displayData, syncedAt }) => + useAudienceTimeBasedCountdown({ + displayData, + timePerTeam: 120, + timePerSpeaking: 30, + syncedAt, + }), + { + initialProps: { + displayData: createDisplayData({ + isRunning: false, + eventType: 'STOP', + prosTime: 30, + revision: 1, + }), + syncedAt: Date.now() as number | null, + }, + }, + ); + + rerender({ + displayData: createDisplayData({ + isRunning: true, + eventType: 'PLAY', + prosTime: 30, + revision: 2, + }), + syncedAt: Date.now() - 400, + }); + + for (let step = 0; step < 30; step += 1) { + act(() => { + vi.advanceTimersByTime(100); + }); + + expect( + (result.current.pros.totalRemainingTime ?? 0) - + (result.current.pros.currentSpeakingRemainingTime ?? 0), + ).toBe(90); + } + + expect(result.current.pros.currentSpeakingRemainingTime).toBe(27); + expect(result.current.pros.totalRemainingTime).toBe(117); + }); + it('TEAM_SWITCH는 새 팀 현재 시간을 초기화하고 실행 상태를 유지한다', () => { const { result, rerender } = renderHook( ({ displayData }) => diff --git a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts index ad953b41..17075aab 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts @@ -8,6 +8,12 @@ interface TeamCountdownInput { speakingTime: number | null; totalSyncKey: number; speakingSyncKey: number; + /** + * 1회당 발언 시간 모드에서 `전체 시간 - 발언 시간` (정수 초) + * - 사회자는 두 시간을 같이 시작·정지하므로 실행 중 차이가 일정하다. + * - 전체 시간을 발언 카운트다운에서 파생해 두 값이 같은 틱에 바뀌게 한다. + */ + totalOffset: number | null; } interface CountdownInputs { @@ -48,6 +54,7 @@ const EMPTY_TEAM_INPUT: TeamCountdownInput = { speakingTime: null, totalSyncKey: 0, speakingSyncKey: 0, + totalOffset: null, }; function createInitialInputs(): CountdownInputs { @@ -70,9 +77,43 @@ function createTeamInput( speakingTime, totalSyncKey: previousInput.totalSyncKey + 1, speakingSyncKey: previousInput.speakingSyncKey + 1, + totalOffset: speakingTime === null ? null : totalTime - speakingTime, }; } +/** + * 발언 시간만 받았을 때의 오프셋 + * - 설정이 막 바뀌어 전체 시간이 아직 흐르지 않았다면 받은 발언 시간 기준으로 다시 계산 + * - 그 외에는 두 시간이 함께 흘렀으므로 기존 오프셋 유지 + */ +function getSpeakingOnlyTotalOffset( + input: TeamCountdownInput, + receivedSpeakingTime: number | null, + hasConfigurationChanged: boolean, +) { + if ( + !hasConfigurationChanged || + receivedSpeakingTime === null || + input.totalTime === null + ) { + return input.totalOffset; + } + + return input.totalTime - receivedSpeakingTime; +} + +function getDerivedTotalTime( + countdownTotal: number | null, + speaking: number | null, + totalOffset: number | null, +) { + if (speaking === null || totalOffset === null) { + return countdownTotal; + } + + return Math.max(0, speaking + totalOffset); +} + function getTeamValue(team: TimeBasedStance, prosValue: T, consValue: T) { return team === 'PROS' ? prosValue : consValue; } @@ -131,9 +172,10 @@ export function useAudienceTimeBasedCountdown({ displayData.currentTeam === 'CONS' && !isConsLocallyStopped; + // 1회당 발언 시간 모드에서는 전체 시간을 발언 카운트다운에서 파생하므로 돌리지 않음 const prosTotalCountdown = useAudienceCountdown({ receivedTime: inputs.pros.totalTime, - isRunning: isProsRequestedToRun, + isRunning: isProsRequestedToRun && timePerSpeaking === null, minimumTime: 0, shouldResetOnRunStateChange: false, syncKey: inputs.pros.totalSyncKey, @@ -147,9 +189,10 @@ export function useAudienceTimeBasedCountdown({ syncKey: inputs.pros.speakingSyncKey, syncedAt, }); + // 1회당 발언 시간 모드에서는 전체 시간을 발언 카운트다운에서 파생하므로 돌리지 않음 const consTotalCountdown = useAudienceCountdown({ receivedTime: inputs.cons.totalTime, - isRunning: isConsRequestedToRun, + isRunning: isConsRequestedToRun && timePerSpeaking === null, minimumTime: 0, shouldResetOnRunStateChange: false, syncKey: inputs.cons.totalSyncKey, @@ -164,10 +207,18 @@ export function useAudienceTimeBasedCountdown({ syncedAt, }); - const prosTotal = prosTotalCountdown.currentSeconds; const prosSpeaking = prosSpeakingCountdown.currentSeconds; - const consTotal = consTotalCountdown.currentSeconds; const consSpeaking = consSpeakingCountdown.currentSeconds; + const prosTotal = getDerivedTotalTime( + prosTotalCountdown.currentSeconds, + prosSpeaking, + inputs.pros.totalOffset, + ); + const consTotal = getDerivedTotalTime( + consTotalCountdown.currentSeconds, + consSpeaking, + inputs.cons.totalOffset, + ); const displaySequence = displayData?.sequence ?? null; const displayRevision = displayData?.revision ?? 0; const displayEventType = displayData?.eventType ?? null; @@ -358,7 +409,14 @@ export function useAudienceTimeBasedCountdown({ ...input, ...(timePerSpeaking === null ? { totalTime: receivedPreviousTime ?? input.totalTime } - : { speakingTime: receivedPreviousTime ?? input.speakingTime }), + : { + speakingTime: receivedPreviousTime ?? input.speakingTime, + totalOffset: getSpeakingOnlyTotalOffset( + input, + receivedPreviousTime, + hasConfigurationChanged, + ), + }), totalSyncKey: timePerSpeaking === null ? input.totalSyncKey + 1 @@ -372,6 +430,8 @@ export function useAudienceTimeBasedCountdown({ ...input, speakingTime: newSpeakingTime, speakingSyncKey: input.speakingSyncKey + 1, + totalOffset: + newSpeakingTime === null ? null : currentTotal - newSpeakingTime, })); return nextInputs; @@ -381,7 +441,14 @@ export function useAudienceTimeBasedCountdown({ ...input, ...(timePerSpeaking === null ? { totalTime: receivedCurrentTime ?? input.totalTime } - : { speakingTime: receivedCurrentTime ?? input.speakingTime }), + : { + speakingTime: receivedCurrentTime ?? input.speakingTime, + totalOffset: getSpeakingOnlyTotalOffset( + input, + receivedCurrentTime, + hasConfigurationChanged, + ), + }), totalSyncKey: timePerSpeaking === null ? input.totalSyncKey + 1 From 143307817119ee686c35a9d73b41eb4291664ea2 Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 27 Sep 2026 01:38:35 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=EC=84=A4=EC=A0=95=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=A7=81=ED=9B=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9D=B4=EC=A0=84=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=8B=9C=EA=B0=84=EC=9D=84=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정 변경과 TEAM_SWITCH가 겹치면 새 팀 전체 시간·발언 시간·오프셋을 이전 순서의 표시값으로 계산하던 문제를 새 설정값 기준으로 바꾼다. Co-Authored-By: Claude Opus 5.5 --- .../useAudienceTimeBasedCountdown.test.ts | 45 +++++++++++++++++++ .../hooks/useAudienceTimeBasedCountdown.ts | 27 +++++------ 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts index cbef8f30..154a29d7 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts @@ -220,6 +220,51 @@ describe('useAudienceTimeBasedCountdown', () => { expect(result.current.cons.currentSpeakingRemainingTime).toBe(29); }); + it('설정 변경과 TEAM_SWITCH가 겹치면 새 팀 전체 시간을 새 설정값으로 표시한다', () => { + const { result, rerender } = renderHook( + ({ displayData, timePerTeam }) => + useAudienceTimeBasedCountdown({ + displayData, + timePerTeam, + timePerSpeaking: 30, + }), + { + initialProps: { + displayData: createDisplayData({ + currentTeam: 'CONS', + isRunning: true, + eventType: 'PLAY', + prosTime: null, + consTime: 30, + revision: 1, + }), + timePerTeam: 120, + }, + }, + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.cons.totalRemainingTime).toBe(118); + + rerender({ + displayData: createDisplayData({ + currentTeam: 'CONS', + isRunning: true, + eventType: 'TEAM_SWITCH', + prosTime: 30, + consTime: null, + sequence: 1, + revision: 2, + }), + timePerTeam: 200, + }); + + expect(result.current.cons.totalRemainingTime).toBe(200); + expect(result.current.cons.currentSpeakingRemainingTime).toBe(30); + }); + it('RESET은 현재 팀의 전체/현재 시간을 API 설정값으로 초기화한다', () => { const { result, rerender } = renderHook( ({ displayData }) => diff --git a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts index 17075aab..3abaecd9 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts @@ -305,13 +305,18 @@ export function useAudienceTimeBasedCountdown({ } : previousInputs; + // 설정이 막 바뀌었다면 표시 중인 값은 이전 순서의 것이므로 새 설정값을 사용 + const getLatestTotal = (team: TimeBasedStance) => + hasConfigurationChanged + ? timePerTeam + : getTeamValue( + team, + latestValuesRef.current.prosTotal, + latestValuesRef.current.consTotal, + ); const currentTeam = displayCurrentTeam; const opponentTeam = currentTeam === 'PROS' ? 'CONS' : 'PROS'; - const latestCurrentTotal = getTeamValue( - currentTeam, - latestValuesRef.current.prosTotal, - latestValuesRef.current.consTotal, - ); + const latestCurrentTotal = getLatestTotal(currentTeam); const currentInput = getTeamValue( currentTeam, nextInputs.pros, @@ -368,11 +373,7 @@ export function useAudienceTimeBasedCountdown({ } if (displayEventType === 'RESET') { - const opponentTotal = getTeamValue( - opponentTeam, - latestValuesRef.current.prosTotal, - latestValuesRef.current.consTotal, - ); + const opponentTotal = getLatestTotal(opponentTeam); const resetSpeakingTime = getNextSpeakingTime({ totalRemainingTime: timePerTeam, timePerSpeaking, @@ -391,11 +392,7 @@ export function useAudienceTimeBasedCountdown({ displayProsTime, displayConsTime, ); - const previousTeamTotal = getTeamValue( - previousTeam, - latestValuesRef.current.prosTotal, - latestValuesRef.current.consTotal, - ); + const previousTeamTotal = getLatestTotal(previousTeam); const newSpeakingTime = getNextSpeakingTime({ totalRemainingTime: currentTotal, timePerSpeaking, From 128e4507312d997e3b5954221c44faff439eade7 Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 27 Sep 2026 01:55:11 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EC=9E=90=EC=9C=A0=ED=86=A0=EB=A1=A0?= =?UTF-8?q?=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=8B=9C=20=ED=98=84=EC=9E=AC=20=ED=84=B4=20=EC=8B=9C=EC=9E=91?= =?UTF-8?q?=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사회자는 순서 진입·팀 전환 시점의 전체/발언 시간을 기억해 초기화 시 그 값으로 되돌리고, RESET 페이로드에 복원값과 양 팀 전체 시간을 담아 발행한다. 청중은 받은 값으로 현재 팀을 되돌린다 (없으면 기존처럼 설정값). Co-Authored-By: Claude Opus 5.5 --- docs/live-share-timer-sync.md | 1 + .../hooks/EventInterpreter.test.ts | 20 +++++ .../hooks/EventInterpreter.ts | 4 +- .../useAudienceTimeBasedCountdown.test.ts | 83 +++++++++++++++++ .../hooks/useAudienceTimeBasedCountdown.ts | 16 ++++ src/page/TimerPage/TimerPage.tsx | 52 +++++++++-- src/page/TimerPage/components/TimerView.tsx | 4 +- .../TimerPage/hooks/useTimeBasedTimer.test.ts | 88 +++++++++++++++++++ src/page/TimerPage/hooks/useTimeBasedTimer.ts | 71 ++++++++------- src/page/TimerPage/hooks/useTimerHotkey.ts | 4 +- 10 files changed, 299 insertions(+), 44 deletions(-) diff --git a/docs/live-share-timer-sync.md b/docs/live-share-timer-sync.md index e186acde..cfd9497c 100644 --- a/docs/live-share-timer-sync.md +++ b/docs/live-share-timer-sync.md @@ -38,6 +38,7 @@ 4. 사회자는 남은 시간을 **올림한 정수 초**로 보내므로 실행 중 스냅샷은 실제보다 최대 1초 큽니다. 실행 중 재동기화 차이가 1초 미만이면 기존 목표 시각과 새 목표 시각 중 **더 이른 쪽**을 택해, 5초 주기 SYNC마다 표시가 튀지 않게 합니다. 5. 자유토론(1회 발언 시간 사용)에서 `PLAY`/`STOP` 등은 발언 시간만 보내므로, 전체 시간을 따로 카운트다운하면 지연 보정이 발언 시간에만 적용되어 두 값의 초 경계가 어긋납니다. 사회자는 두 시간을 같이 시작·정지하므로, 청중은 `전체 − 발언` 차이를 오프셋으로 두고 **전체 시간 = 발언 시간 + 오프셋**으로 파생해 두 값이 같은 틱에 바뀌게 합니다. 오프셋은 설정 변경·`SYNC`·`RESET`·`TEAM_SWITCH`(새 팀)에서만 다시 계산합니다. (`useAudienceTimeBasedCountdown`) +6. 자유토론 초기화(`RESET`)는 순서 설정값이 아니라 **현재 턴을 시작했던 시간**(순서 진입 또는 팀 전환 시점의 전체/발언 시간)으로 되돌립니다. 이벤트 발행 시점의 React 상태는 아직 초기화 전 값이므로, 사회자는 턴 시작 시간을 직접 읽어 `remainingTime`과 양 팀 전체 시간(`prosRemainingTime`/`consRemainingTime`)에 담아 보냅니다. 청중은 이 값으로 현재 팀을 되돌리고, 양 팀 전체 시간이 없으면(이전 버전 사회자) 설정값으로 초기화합니다. (`useTimeBasedTimer.getTurnStartTimes`, `useAudienceTimeBasedCountdown`) - 보정하지 않는 구간: 사회자 → 서버 구간 지연, 1초 이상의 비정상 지연. - 청중 간 차이는 정수 초 표시 특성상 최대 1초까지 날 수 있습니다. diff --git a/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts b/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts index f33417d6..00a53633 100644 --- a/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts +++ b/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts @@ -358,6 +358,26 @@ describe('eventInterpreter 순수 함수', () => { sequence: 0, }); }); + + it('TIME_BASED는 초기화된 현재 팀 시간과 양 팀 전체 시간을 담는다', () => { + const payload: TimerDataPayload = { + timerType: 'TIME_BASED', + remainingTime: 30, + sequence: 1, + currentTeam: 'CONS', + prosRemainingTime: 80, + consRemainingTime: 94, + }; + + const result = getDisplayDataByEvent('RESET', payload, null, mockTable); + expect(result).toMatchObject({ + timerType: 'TIME_BASED', + currentTeam: 'CONS', + isRunning: false, + consTime: 30, + teamTotalTimes: { pros: 80, cons: 94 }, + }); + }); }); describe('BEFORE 이벤트', () => { diff --git a/src/page/AudienceSharePage/hooks/EventInterpreter.ts b/src/page/AudienceSharePage/hooks/EventInterpreter.ts index eb249b5d..e65fd22f 100644 --- a/src/page/AudienceSharePage/hooks/EventInterpreter.ts +++ b/src/page/AudienceSharePage/hooks/EventInterpreter.ts @@ -111,7 +111,7 @@ export function createDisplayData( } /** - * 자유토론 SYNC 페이로드에서 양 팀의 총 남은 시간을 꺼냅니다. + * 자유토론 SYNC/RESET 페이로드에서 양 팀의 총 남은 시간을 꺼냅니다. * 둘 중 하나라도 없으면 `undefined`를 반환합니다. */ function getTeamTotalTimes( @@ -211,6 +211,8 @@ export function getDisplayDataByEvent( data.sequence, data.remainingTime, ), + // 자유토론은 사회자가 현재 턴 시작 시간으로 되돌린 값을 보냄 + teamTotalTimes: getTeamTotalTimes(data), }); case 'BEFORE': { diff --git a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts index 154a29d7..c03883e9 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts @@ -301,6 +301,89 @@ describe('useAudienceTimeBasedCountdown', () => { expect(result.current.pros.isRunning).toBe(false); }); + it('RESET은 받은 턴 시작 시간으로 현재 팀을 되돌린다', () => { + const { result, rerender } = renderHook( + ({ displayData }) => + useAudienceTimeBasedCountdown({ + displayData, + timePerTeam: 120, + timePerSpeaking: 30, + }), + { + initialProps: { + displayData: createDisplayData({ + currentTeam: 'CONS', + isRunning: true, + eventType: 'SYNC', + prosTime: null, + consTime: 30, + teamTotalTimes: { pros: 80, cons: 94 }, + revision: 1, + }), + }, + }, + ); + + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current.cons.totalRemainingTime).toBe(89); + + rerender({ + displayData: createDisplayData({ + currentTeam: 'CONS', + isRunning: false, + eventType: 'RESET', + prosTime: null, + consTime: 30, + teamTotalTimes: { pros: 80, cons: 94 }, + revision: 2, + }), + }); + + expect(result.current.cons.totalRemainingTime).toBe(94); + expect(result.current.cons.currentSpeakingRemainingTime).toBe(30); + expect(result.current.pros.totalRemainingTime).toBe(80); + expect(result.current.cons.isRunning).toBe(false); + }); + + it('전체 시간만 쓰는 RESET은 받은 턴 시작 전체 시간으로 되돌린다', () => { + const { result, rerender } = renderHook( + ({ displayData }) => + useAudienceTimeBasedCountdown({ + displayData, + timePerTeam: 120, + timePerSpeaking: null, + }), + { + initialProps: { + displayData: createDisplayData({ + isRunning: true, + eventType: 'PLAY', + prosTime: 94, + revision: 1, + }), + }, + }, + ); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + rerender({ + displayData: createDisplayData({ + isRunning: false, + eventType: 'RESET', + prosTime: 94, + teamTotalTimes: { pros: 94, cons: 120 }, + revision: 2, + }), + }); + + expect(result.current.pros.totalRemainingTime).toBe(94); + }); + it('전체 시간 또는 현재 시간이 끝나면 0초에서 함께 정지한다', () => { const { result } = renderHook(() => useAudienceTimeBasedCountdown({ diff --git a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts index 3abaecd9..2c563516 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts @@ -373,6 +373,22 @@ export function useAudienceTimeBasedCountdown({ } if (displayEventType === 'RESET') { + // 사회자는 현재 턴을 시작했던 시간으로 되돌린 값을 양 팀 전체 시간과 함께 보냄 + if (displayProsTotalTime !== null && displayConsTotalTime !== null) { + const turnStartTotal = getTeamValue( + currentTeam, + displayProsTotalTime, + displayConsTotalTime, + ); + const turnStartSpeakingTime = + timePerSpeaking === null ? null : receivedCurrentTime; + + return updateTeamInput(nextInputs, currentTeam, (input) => + createTeamInput(turnStartTotal, turnStartSpeakingTime, input), + ); + } + + // 양 팀 전체 시간이 없는 이전 버전 사회자는 설정값으로 초기화 const opponentTotal = getLatestTotal(opponentTeam); const resetSpeakingTime = getNextSpeakingTime({ totalRemainingTime: timePerTeam, diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index 72ecd0f6..5958d5df 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -41,6 +41,12 @@ import { buildTimerPayloadForShare } from './buildTimerPayloadForShare'; const IS_LIVE_SHARE_ENABLED = true; // 토론 타이머 실행, 라운드 이동, 종료 흐름을 관리하는 메인 페이지다. +interface SharedTimes { + remainingTime: number | null; + prosTotalTime: number | null; + consTotalTime: number | null; +} + export default function TimerPage() { const { t } = useTranslation(); const [answerTime, setAnswerTime] = useState(30); @@ -129,7 +135,7 @@ export default function TimerPage() { const prosTotalTime = timer1.totalTimer; const consTotalTime = timer2.totalTimer; const buildTimerPayload = useCallback( - (eventType: TimerEventTypes) => { + (eventType: TimerEventTypes, sharedTimes?: SharedTimes) => { const isTimeBasedTimerRunning = prosConsSelected === 'PROS' ? isProsTimerRunning : isConsTimerRunning; @@ -138,13 +144,13 @@ export default function TimerPage() { timerType, sequence: index, currentTeam: prosConsSelected, - remainingTime, + remainingTime: sharedTimes ? sharedTimes.remainingTime : remainingTime, isCurrentTimerRunning: timerType === 'NORMAL' ? isNormalTimerRunning : isTimeBasedTimerRunning, - prosTotalTime, - consTotalTime, + prosTotalTime: sharedTimes ? sharedTimes.prosTotalTime : prosTotalTime, + consTotalTime: sharedTimes ? sharedTimes.consTotalTime : consTotalTime, }); }, [ @@ -160,6 +166,39 @@ export default function TimerPage() { ], ); + // 자유토론 초기화 직후 공유할 시간을 만든다. + // 이벤트 발행 시점에는 React 상태가 아직 초기화 전 값이므로, 현재 팀은 턴 시작 시간을 직접 읽는다. + const getResetSharedTimes = (): SharedTimes | undefined => { + if (timerType !== 'TIME_BASED') { + return undefined; + } + + const prosTimes = + prosConsSelected === 'PROS' ? timer1.getTurnStartTimes() : timer1; + const consTimes = + prosConsSelected === 'CONS' ? timer2.getTurnStartTimes() : timer2; + + return { + remainingTime: getRemainingTimeForShare({ + timerType, + normalTimer: normalTimer.timer, + currentTeam: prosConsSelected, + prosTimer: { + totalTimer: prosTimes.totalTimer, + speakingTimer: prosTimes.speakingTimer, + isSpeakingTimerAvailable: timer1.isSpeakingTimerAvailable, + }, + consTimer: { + totalTimer: consTimes.totalTimer, + speakingTimer: consTimes.speakingTimer, + isSpeakingTimerAvailable: timer2.isSpeakingTimerAvailable, + }, + }), + prosTotalTime: prosTimes.totalTimer, + consTotalTime: consTimes.totalTimer, + }; + }; + // 타이머 이벤트를 핸들링하는 래퍼 함수 선언 const handleTimerEvent = (invoke: () => void, eventType: SocketEventType) => { // 이벤트 실행 @@ -178,7 +217,10 @@ export default function TimerPage() { return; } - const payload = buildTimerPayload(eventType); + const payload = buildTimerPayload( + eventType, + eventType === 'RESET' ? getResetSharedTimes() : undefined, + ); if (payload === null) { return; } diff --git a/src/page/TimerPage/components/TimerView.tsx b/src/page/TimerPage/components/TimerView.tsx index 722cf633..751a2880 100644 --- a/src/page/TimerPage/components/TimerView.tsx +++ b/src/page/TimerPage/components/TimerView.tsx @@ -90,7 +90,7 @@ export default function TimerView({ startTimer: () => onEvent(timer1.startTimer, 'PLAY'), pauseTimer: () => onEvent(timer1.pauseTimer, 'STOP'), resetCurrentTimer: () => - onEvent(() => timer1.resetCurrentTimer(timer2.isDone), 'RESET'), + onEvent(() => timer1.resetCurrentTimer(), 'RESET'), }} item={data.table[index]} isSelected={prosConsSelected === 'PROS'} @@ -130,7 +130,7 @@ export default function TimerView({ startTimer: () => onEvent(timer2.startTimer, 'PLAY'), pauseTimer: () => onEvent(timer2.pauseTimer, 'STOP'), resetCurrentTimer: () => - onEvent(() => timer2.resetCurrentTimer(timer1.isDone), 'RESET'), + onEvent(() => timer2.resetCurrentTimer(), 'RESET'), }} item={data.table[index]} isSelected={prosConsSelected === 'CONS'} diff --git a/src/page/TimerPage/hooks/useTimeBasedTimer.test.ts b/src/page/TimerPage/hooks/useTimeBasedTimer.test.ts index 26697cbb..4a152954 100644 --- a/src/page/TimerPage/hooks/useTimeBasedTimer.test.ts +++ b/src/page/TimerPage/hooks/useTimeBasedTimer.test.ts @@ -75,4 +75,92 @@ describe('useTimeBasedTimer', () => { expect(result.current.totalTimer).toBe(300); }); }); + + describe('resetCurrentTimer', () => { + function renderTimerWithTurnStartedAt( + totalTimer: number, + isOpponentDone = false, + ) { + const { result } = renderHook(() => useTimeBasedTimer()); + + act(() => { + result.current.setDefaultTime({ + defaultTotalTimer: 120, + defaultSpeakingTimer: 30, + }); + result.current.setTimers(totalTimer, 3); + }); + act(() => { + result.current.resetTimerForNextPhase(isOpponentDone); + }); + + return result; + } + + it('진행 중 초기화하면 현재 턴을 시작했던 전체/발언 시간으로 되돌린다', () => { + const result = renderTimerWithTurnStartedAt(94); + + act(() => { + result.current.startTimer(); + }); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current.totalTimer).toBe(89); + expect(result.current.speakingTimer).toBe(25); + + act(() => { + result.current.resetCurrentTimer(); + }); + + expect(result.current.totalTimer).toBe(94); + expect(result.current.speakingTimer).toBe(30); + expect(result.current.isRunning).toBe(false); + expect(result.current.getTurnStartTimes()).toEqual({ + totalTimer: 94, + speakingTimer: 30, + }); + }); + + it('상대 팀 시간이 끝난 턴이면 남은 전체 시간을 발언 시간으로 되돌린다', () => { + const result = renderTimerWithTurnStartedAt(94, true); + + act(() => { + result.current.startTimer(); + }); + act(() => { + vi.advanceTimersByTime(5000); + }); + act(() => { + result.current.resetCurrentTimer(); + }); + + expect(result.current.totalTimer).toBe(94); + expect(result.current.speakingTimer).toBe(94); + }); + + it('순서에 들어온 뒤 팀 전환 없이 초기화하면 설정값으로 되돌린다', () => { + const { result } = renderHook(() => useTimeBasedTimer()); + + act(() => { + result.current.setDefaultTime({ + defaultTotalTimer: 120, + defaultSpeakingTimer: 30, + }); + result.current.setTimers(120, 30); + }); + act(() => { + result.current.startTimer(); + }); + act(() => { + vi.advanceTimersByTime(5000); + }); + act(() => { + result.current.resetCurrentTimer(); + }); + + expect(result.current.totalTimer).toBe(120); + expect(result.current.speakingTimer).toBe(30); + }); + }); }); diff --git a/src/page/TimerPage/hooks/useTimeBasedTimer.ts b/src/page/TimerPage/hooks/useTimeBasedTimer.ts index 0e9f6bfd..16d10bd0 100644 --- a/src/page/TimerPage/hooks/useTimeBasedTimer.ts +++ b/src/page/TimerPage/hooks/useTimeBasedTimer.ts @@ -48,6 +48,13 @@ export function useTimeBasedTimer(): TimeBasedTimerLogics { speakingTimerRef.current = speakingTimer; }, [speakingTimer]); + // 현재 턴을 시작했던 전체/발언 시간 (초기화 시 이 값으로 되돌림) + // - 순서 진입(setTimers)과 팀 전환(resetTimerForNextPhase) 시점에 기록 + const turnStartTimesRef = useRef({ + totalTimer: null, + speakingTimer: null, + }); + /** * 타이머 시작을 위해 사용하는 저수준 함수 */ @@ -132,43 +139,27 @@ export function useTimeBasedTimer(): TimeBasedTimerLogics { }, []); /** - * 최근 저장된 시간(savedTime)으로 복원 + * 현재 턴을 시작했던 시간으로 복원 */ - const resetCurrentTimer = useCallback( - (isOpponentDone: boolean) => { - // 초기화를 위해 타이머 정지 - pauseTimer(); - - // 타이머가 초기화되었으니 이제부터는 당연히 다시 동작 가능하기 때문에, - // isDone을 false로 설정 - setIsDone(false); + const resetCurrentTimer = useCallback(() => { + // 초기화를 위해 타이머 정지 + pauseTimer(); - // 전체 발언 시간 복원 - setTotalTimer(defaultTime.defaultTotalTimer); + // 타이머가 초기화되었으니 이제부터는 당연히 다시 동작 가능하기 때문에, + // isDone을 false로 설정 + setIsDone(false); - // 1회당 발언 시간 사용하는지 여부와 유효성 확인 - if ( - !isSpeakingTimerAvailable || - defaultTime.defaultSpeakingTimer === null || - totalTimer === null - ) { - return; - } + setTotalTimer(turnStartTimesRef.current.totalTimer); + setSpeakingTimer(turnStartTimesRef.current.speakingTimer); + }, [pauseTimer]); - // 상대편 발언 종료 여부에 따라 1회당 발언 시간 다르게 계산 - if (isOpponentDone) { - setSpeakingTimer(defaultTime.defaultTotalTimer); - } else { - setSpeakingTimer(defaultTime.defaultSpeakingTimer); - } - }, - [ - isSpeakingTimerAvailable, - defaultTime.defaultSpeakingTimer, - defaultTime.defaultTotalTimer, - totalTimer, - pauseTimer, - ], + /** + * 현재 턴을 시작했던 전체/발언 시간 + * - 초기화 직후 상태를 청중에게 공유할 때 사용 + */ + const getTurnStartTimes = useCallback( + (): CaughtUpTimes => turnStartTimesRef.current, + [], ); /** @@ -194,11 +185,16 @@ export function useTimeBasedTimer(): TimeBasedTimerLogics { // 계산한 시간을 1회당 발언 시간으로 설정 setSpeakingTimer(nextSpeakingTime); + turnStartTimesRef.current = { + totalTimer, + speakingTimer: nextSpeakingTime, + }; return nextSpeakingTime; } else { // # 1회당 발언 시간을 사용하지 않을 경우 // 전체 발언 시간 타이머는 초기값으로 리셋 + turnStartTimesRef.current = { totalTimer, speakingTimer: null }; if (totalTimer === 0 || totalTimer === null) { return 0; } @@ -253,6 +249,10 @@ export function useTimeBasedTimer(): TimeBasedTimerLogics { pauseTimer(); setTotalTimer(total); setSpeakingTimer(speaking); + turnStartTimesRef.current = { + totalTimer: total, + speakingTimer: speaking, + }; }, [pauseTimer], ); @@ -267,6 +267,7 @@ export function useTimeBasedTimer(): TimeBasedTimerLogics { setTotalTimer(null); setSpeakingTimer(null); setIsDone(false); + turnStartTimesRef.current = { totalTimer: null, speakingTimer: null }; intervalRef.current = null; }, [pauseTimer]); @@ -319,6 +320,7 @@ export function useTimeBasedTimer(): TimeBasedTimerLogics { resetTimerForNextPhase, resetAndStartTimer, resetCurrentTimer, + getTurnStartTimes, setTimers, setDefaultTime, setIsDone, @@ -346,7 +348,8 @@ export interface TimeBasedTimerLogics { pauseTimer: () => void; resetTimerForNextPhase: (isOpponentDone: boolean) => number; resetAndStartTimer: (isOpponentDone: boolean) => void; - resetCurrentTimer: (isOpponentDone: boolean) => void; + resetCurrentTimer: () => void; + getTurnStartTimes: () => CaughtUpTimes; setTimers: (total: number | null, speaking?: number | null) => void; setDefaultTime: Dispatch< SetStateAction<{ diff --git a/src/page/TimerPage/hooks/useTimerHotkey.ts b/src/page/TimerPage/hooks/useTimerHotkey.ts index a8d1e2fa..d88c1b9b 100644 --- a/src/page/TimerPage/hooks/useTimerHotkey.ts +++ b/src/page/TimerPage/hooks/useTimerHotkey.ts @@ -91,9 +91,9 @@ export function useTimerHotkey( onEvent(normalTimer.resetTimer, 'RESET'); } else { if (prosConsSelected === 'PROS') { - onEvent(() => timer1.resetCurrentTimer(timer2.isDone), 'RESET'); + onEvent(() => timer1.resetCurrentTimer(), 'RESET'); } else { - onEvent(() => timer2.resetCurrentTimer(timer1.isDone), 'RESET'); + onEvent(() => timer2.resetCurrentTimer(), 'RESET'); } } break; From f3d19bfd7eecd95ff4cd1eb9f7a589c90e3aeda1 Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 27 Sep 2026 02:02:30 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=201=ED=9A=8C=EB=8B=B9=20=EB=B0=9C?= =?UTF-8?q?=EC=96=B8=20=EC=8B=9C=EA=B0=84=20=EC=97=86=EB=8A=94=20=EC=9E=90?= =?UTF-8?q?=EC=9C=A0=ED=86=A0=EB=A1=A0=EC=97=90=EC=84=9C=20=EB=B0=9C?= =?UTF-8?q?=EC=96=B8=20=EC=8B=9C=EA=B0=84=20=EB=B7=B0=EB=A1=9C=20=EB=B0=94?= =?UTF-8?q?=EB=80=8C=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 앞 순서의 발언 목표 시각이 남아 SYNC heartbeat의 catchUpToClock이 발언 시간을 되살렸고, 모드를 남은 발언 시간 값으로 판단해 뷰·완료 판정·공유값이 바뀌었다. 모드는 순서 설정값으로 판단하고, 순서 이동·시작 시 발언 목표 시각을 비운다. Co-Authored-By: Claude Opus 5.5 --- .../TimerPage/components/TimeBasedTimer.tsx | 9 +- .../TimerPage/hooks/useTimeBasedTimer.test.ts | 92 +++++++++++++++++++ src/page/TimerPage/hooks/useTimeBasedTimer.ts | 20 ++-- src/page/TimerPage/hooks/useTimerPageState.ts | 7 +- 4 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/page/TimerPage/components/TimeBasedTimer.tsx b/src/page/TimerPage/components/TimeBasedTimer.tsx index 23e3bf98..509699d9 100644 --- a/src/page/TimerPage/components/TimeBasedTimer.tsx +++ b/src/page/TimerPage/components/TimeBasedTimer.tsx @@ -59,8 +59,11 @@ export default function TimeBasedTimer({ Math.abs((speakingTimer ?? 0) % 60), ); + // 1회당 발언 시간 사용 여부는 순서 설정값으로 판단 + const isSpeakingTimerMode = item.timePerSpeaking !== null; + const initRawProgress = (): number => { - if (speakingTimer === null) { + if (!isSpeakingTimerMode) { // 1회당 발언 시간 X일 때... if (item.timePerTeam && totalTimer && item.timePerTeam > 0) { // 팀당 발언 시간 타이머가 정상 동작 중이고 남은 시간이 있을 경우, 진행도를 계산 @@ -136,7 +139,7 @@ export default function TimeBasedTimer({ className="size-[400px] xl:size-[560px]" > {/* 1회당 발언 시간 X */} - {speakingTimer === null && ( + {!isSpeakingTimerMode && (

{minute}

:

@@ -145,7 +148,7 @@ export default function TimeBasedTimer({ )} {/* 1회당 발언 시간 O */} - {speakingTimer !== null && ( + {isSpeakingTimerMode && (

{t('전체 시간')} diff --git a/src/page/TimerPage/hooks/useTimeBasedTimer.test.ts b/src/page/TimerPage/hooks/useTimeBasedTimer.test.ts index 4a152954..fc7ebca4 100644 --- a/src/page/TimerPage/hooks/useTimeBasedTimer.test.ts +++ b/src/page/TimerPage/hooks/useTimeBasedTimer.test.ts @@ -17,6 +17,10 @@ describe('useTimeBasedTimer', () => { const { result } = renderHook(() => useTimeBasedTimer()); act(() => { + result.current.setDefaultTime({ + defaultTotalTimer: 300, + defaultSpeakingTimer: 60, + }); result.current.setTimers(300, 60); }); act(() => { @@ -39,6 +43,10 @@ describe('useTimeBasedTimer', () => { const { result } = renderHook(() => useTimeBasedTimer()); act(() => { + result.current.setDefaultTime({ + defaultTotalTimer: 30, + defaultSpeakingTimer: 10, + }); result.current.setTimers(30, 10); }); act(() => { @@ -58,6 +66,10 @@ describe('useTimeBasedTimer', () => { const { result } = renderHook(() => useTimeBasedTimer()); act(() => { + result.current.setDefaultTime({ + defaultTotalTimer: 300, + defaultSpeakingTimer: 60, + }); result.current.setTimers(300, 60); }); @@ -76,6 +88,86 @@ describe('useTimeBasedTimer', () => { }); }); + describe('1회당 발언 시간이 없는 순서', () => { + it('앞 순서에서 발언 시간을 썼어도 전체 시간만 진행한다', () => { + const { result } = renderHook(() => useTimeBasedTimer()); + + // 앞 순서: 전체 2분 + 1회 발언 30초 + act(() => { + result.current.setDefaultTime({ + defaultTotalTimer: 120, + defaultSpeakingTimer: 30, + }); + result.current.setTimers(120, 30); + }); + act(() => { + result.current.startTimer(); + }); + act(() => { + vi.advanceTimersByTime(3000); + }); + + // 다음 순서: 전체 1분만 + act(() => { + result.current.clearTimer(); + result.current.setDefaultTime({ + defaultTotalTimer: 60, + defaultSpeakingTimer: null, + }); + result.current.setTimers(60, null); + }); + act(() => { + result.current.startTimer(); + }); + act(() => { + vi.advanceTimersByTime(2000); + }); + + let caughtUp: { + totalTimer: number | null; + speakingTimer: number | null; + } = { totalTimer: null, speakingTimer: null }; + act(() => { + caughtUp = result.current.catchUpToClock(); + }); + act(() => { + result.current.pauseTimer(); + }); + + expect(caughtUp).toEqual({ totalTimer: 58, speakingTimer: null }); + expect(result.current.totalTimer).toBe(58); + expect(result.current.speakingTimer).toBeNull(); + expect(result.current.isSpeakingTimerAvailable).toBe(false); + }); + + it('초기화하면 턴을 시작했던 전체 시간으로 되돌린다', () => { + const { result } = renderHook(() => useTimeBasedTimer()); + + act(() => { + result.current.setDefaultTime({ + defaultTotalTimer: 60, + defaultSpeakingTimer: null, + }); + result.current.setTimers(45, null); + }); + act(() => { + result.current.resetTimerForNextPhase(false); + }); + act(() => { + result.current.startTimer(); + }); + act(() => { + vi.advanceTimersByTime(5000); + }); + act(() => { + result.current.resetCurrentTimer(); + }); + + expect(result.current.totalTimer).toBe(45); + expect(result.current.speakingTimer).toBeNull(); + }); + }); + describe('resetCurrentTimer', () => { function renderTimerWithTurnStartedAt( totalTimer: number, diff --git a/src/page/TimerPage/hooks/useTimeBasedTimer.ts b/src/page/TimerPage/hooks/useTimeBasedTimer.ts index 16d10bd0..ee0cb1df 100644 --- a/src/page/TimerPage/hooks/useTimeBasedTimer.ts +++ b/src/page/TimerPage/hooks/useTimeBasedTimer.ts @@ -17,7 +17,6 @@ export function useTimeBasedTimer(): TimeBasedTimerLogics { // 발언당 시간 타이머(=각 phase별 제한시간, 모드 전환 가능) const [speakingTimer, setSpeakingTimer] = useState(null); - const isSpeakingTimerAvailable = speakingTimer !== null; // 기본(초기) 시간값 (reset 등에서 참조) const [defaultTime, setDefaultTime] = useState<{ @@ -25,6 +24,10 @@ export function useTimeBasedTimer(): TimeBasedTimerLogics { defaultSpeakingTimer: number | null; }>({ defaultTotalTimer: 0, defaultSpeakingTimer: null }); + // 1회당 발언 시간 사용 여부는 순서 설정값으로 판단 + // (남은 발언 시간 값으로 판단하면 이전 순서의 값이 섞였을 때 모드가 바뀜) + const isSpeakingTimerAvailable = defaultTime.defaultSpeakingTimer !== null; + // 현재 타이머 동작중 여부 const [isRunning, setIsRunning] = useState(false); @@ -112,9 +115,10 @@ export function useTimeBasedTimer(): TimeBasedTimerLogics { // 해당 시간을 목표 시간으로 두는 식임 const startTime = Date.now(); targetTimeRef.current = startTime + totalTimer * 1000; - if (isSpeakingTimerAvailable) { - speakingTargetTimeRef.current = startTime + speakingTimer * 1000; - } + speakingTargetTimeRef.current = + isSpeakingTimerAvailable && speakingTimer !== null + ? startTime + speakingTimer * 1000 + : null; // 타이머 인터벌 시작 setTimerInterval(); @@ -224,9 +228,9 @@ export function useTimeBasedTimer(): TimeBasedTimerLogics { // 해당 시간을 목표 시간으로 두는 식임 const startTime = Date.now(); targetTimeRef.current = startTime + totalTimer * 1000; - if (isSpeakingTimerAvailable) { - speakingTargetTimeRef.current = startTime + newTime * 1000; - } + speakingTargetTimeRef.current = isSpeakingTimerAvailable + ? startTime + newTime * 1000 + : null; // 타이머 인터벌 시작 setTimerInterval(); @@ -268,6 +272,8 @@ export function useTimeBasedTimer(): TimeBasedTimerLogics { setSpeakingTimer(null); setIsDone(false); turnStartTimesRef.current = { totalTimer: null, speakingTimer: null }; + targetTimeRef.current = null; + speakingTargetTimeRef.current = null; intervalRef.current = null; }, [pauseTimer]); diff --git a/src/page/TimerPage/hooks/useTimerPageState.ts b/src/page/TimerPage/hooks/useTimerPageState.ts index 7a2c6fb9..da60f640 100644 --- a/src/page/TimerPage/hooks/useTimerPageState.ts +++ b/src/page/TimerPage/hooks/useTimerPageState.ts @@ -271,10 +271,9 @@ export function useTimerPageState(tableId: number): TimerPageLogics { useEffect(() => { const selectedTimer = prosConsSelected === 'PROS' ? timer1 : timer2; - const isDone = - selectedTimer.speakingTimer === null - ? selectedTimer.totalTimer === 0 - : selectedTimer.speakingTimer === 0; + const isDone = selectedTimer.isSpeakingTimerAvailable + ? selectedTimer.speakingTimer === 0 + : selectedTimer.totalTimer === 0; if (isDone) { selectedTimer.setIsDone(true); From 529e0e741f65b2acf9590386fcf5ee8deb3a66c5 Mon Sep 17 00:00:00 2001 From: coli Date: Sun, 27 Sep 2026 02:10:08 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EC=A7=84=EC=98=81=EC=9D=B4=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20TEAM=5FSWITCH=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EB=B0=9C=ED=96=89=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상대 팀 시간이 모두 소진되면 사회자 화면은 진영을 넘기지 않지만 ENTER 키/버튼은 TEAM_SWITCH를 발행해 청중에게는 주도권이 넘어간 것처럼 보였다. 전환 가능 여부(canSwitchCamp)를 확인한 뒤에만 발행하고, L 키가 찬성 팀 타이머를 확인하던 오류도 수정한다. Co-Authored-By: Claude Opus 5.5 --- src/page/TimerPage/components/TimerView.tsx | 8 ++- .../TimerPage/hooks/useTimerHotkey.test.ts | 53 +++++++++++++++++++ src/page/TimerPage/hooks/useTimerHotkey.ts | 10 ++-- src/page/TimerPage/hooks/useTimerPageState.ts | 9 +++- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/page/TimerPage/components/TimerView.tsx b/src/page/TimerPage/components/TimerView.tsx index 751a2880..cc2d04dc 100644 --- a/src/page/TimerPage/components/TimerView.tsx +++ b/src/page/TimerPage/components/TimerView.tsx @@ -28,6 +28,7 @@ export default function TimerView({ isAdditionalTimerAvailable, handleActivateTeam, switchCamp, + canSwitchCamp, } = state; const { answerTimerState, handleClickAnswerTimer } = useAnswerTimer({ @@ -114,7 +115,12 @@ export default function TimerView({ {/* ENTER 버튼 */}