From 4c8a04fe072b64cd43c30066a4464faaa11c39b7 Mon Sep 17 00:00:00 2001 From: jjangminii Date: Sun, 23 Aug 2026 16:44:41 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor(web):=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=A8=B8=20=EC=BF=BC=EB=A6=AC=20=EB=AC=B4=ED=9A=A8=ED=99=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=ED=86=B5=EC=9D=BC=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 타이머 mutation별로 흩어져 있던 쿼리 무효화 호출을 invalidateTimerProgress(일시정지/재개/연장)와 invalidateTimerFinish(완료/중지) 두 헬퍼로 통일했습니다 - 기존 invalidateTimerState를 invalidateTimerProgress로 이름을 맞춰 다른 화면(home/today)에도 동일하게 적용했습니다 --- .../home/_hooks/use-home-todos-by-date.ts | 8 +++--- .../today/_hooks/useTodayTodoList.ts | 8 +++--- .../layout/sidebar/time/TimerPanel.tsx | 25 +++++-------------- .../timer/use-timer-query-invalidation.ts | 17 +++++++++++-- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts index a5cd937f..bbe78b73 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts @@ -55,7 +55,7 @@ export const useHomeTodosByDate = ( const { invalidateHomeView, invalidateStatistics, - invalidateTimerState, + invalidateTimerProgress, invalidateTimeBoxes, invalidateFocusTodo, } = useTimerQueryInvalidation(); @@ -64,7 +64,7 @@ export const useHomeTodosByDate = ( useStartTimer({ mutation: { onSuccess: () => { - invalidateTimerState(); + invalidateTimerProgress(); invalidateFocusTodo(); }, }, @@ -73,7 +73,7 @@ export const useHomeTodosByDate = ( useChangeStatus({ mutation: { onSuccess: () => { - invalidateTimerState(); + invalidateTimerProgress(); invalidateFocusTodo(); }, }, @@ -153,7 +153,7 @@ export const useHomeTodosByDate = ( { onSuccess: (response) => { onStopFeedback(response.data?.aiFeedback ?? undefined); - invalidateTimerState(); + invalidateTimerProgress(); updateTodo(dateKey, todoId, (todo) => ({ ...todo, diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts index a42f0ab4..4958c916 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts @@ -48,7 +48,7 @@ export const useTodayTodoList = ( const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); const { mutate: stopTimer } = useStopTimer(); const { - invalidateTimerState, + invalidateTimerProgress, invalidateTimeBoxes, invalidateTodayView, invalidateFocusTodo, @@ -58,7 +58,7 @@ export const useTodayTodoList = ( const { mutate: startTimer, isPending: isStartTimerPending } = useStartTimer({ mutation: { onSuccess: () => { - invalidateTimerState(); + invalidateTimerProgress(); invalidateFocusTodo(); }, }, @@ -68,7 +68,7 @@ export const useTodayTodoList = ( useChangeStatus({ mutation: { onSuccess: () => { - invalidateTimerState(); + invalidateTimerProgress(); invalidateFocusTodo(); }, }, @@ -141,7 +141,7 @@ export const useTodayTodoList = ( { onSuccess: (response) => { onStopFeedback(response.data?.aiFeedback ?? undefined); - invalidateTimerState(); + invalidateTimerProgress(); updateTodo(todoId, (todo) => ({ ...todo, completed: true })); changeTodoStatus( diff --git a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx index 2f4bc03d..afeae0f4 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx @@ -30,28 +30,25 @@ export const TimerPanel = () => { const { data: activeTimer } = useActiveTimer(); const { - invalidateActiveTimer, invalidateHomeView, invalidateTimeBoxes, invalidateTodayView, invalidateFocusTodo, - invalidateTodoDetail, + invalidateTimerProgress, + invalidateTimerFinish, } = useTimerQueryInvalidation(); const { mutate: changeStatus } = useChangeStatus({ mutation: { onSuccess: () => { - invalidateActiveTimer(); - invalidateHomeView(); - invalidateTimeBoxes(); + invalidateTimerProgress(); }, }, }); const { mutate: extendTimer } = useExtendTimer({ mutation: { onSuccess: () => { - invalidateActiveTimer(); - invalidateTimeBoxes(); + invalidateTimerProgress(); }, }, }); @@ -59,12 +56,7 @@ export const TimerPanel = () => { mutation: { onSuccess: (response) => { setFeedbackText(response.data?.aiFeedback ?? undefined); - invalidateActiveTimer(); - invalidateHomeView(); - invalidateTimeBoxes(); - invalidateTodayView(); - invalidateFocusTodo(); - if (response.data?.todoId) invalidateTodoDetail(response.data.todoId); + invalidateTimerFinish(response.data?.todoId); }, }, }); @@ -72,12 +64,7 @@ export const TimerPanel = () => { mutation: { onSuccess: (response) => { setFeedbackText(response.data?.aiFeedback ?? undefined); - invalidateActiveTimer(); - invalidateHomeView(); - invalidateTimeBoxes(); - invalidateTodayView(); - invalidateFocusTodo(); - if (response.data?.todoId) invalidateTodoDetail(response.data.todoId); + invalidateTimerFinish(response.data?.todoId); }, }, }); diff --git a/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts b/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts index e7c0cd05..04c42fe1 100644 --- a/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts +++ b/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts @@ -30,11 +30,23 @@ export const useTimerQueryInvalidation = () => { queryClient.invalidateQueries({ queryKey: getGetTodoDetailQueryKey(todoId), }); - const invalidateTimerState = () => { + + /** 일시정지/재개/연장처럼 타이머가 계속 진행 중인 액션 이후 무효화 */ + const invalidateTimerProgress = () => { + invalidateActiveTimer(); + invalidateHomeView(); + invalidateTimeBoxes(); + }; + + /** 완료/중지처럼 타이머가 종료되는 액션 이후 무효화 */ + const invalidateTimerFinish = (todoId?: number) => { invalidateActiveTimer(); invalidateHomeView(); invalidateTimeBoxes(); + invalidateTodayView(); invalidateStatistics(); + invalidateFocusTodo(); + if (todoId) invalidateTodoDetail(todoId); }; return { @@ -45,6 +57,7 @@ export const useTimerQueryInvalidation = () => { invalidateTodayView, invalidateFocusTodo, invalidateTodoDetail, - invalidateTimerState, + invalidateTimerProgress, + invalidateTimerFinish, }; }; From c43cbed7f36d2ddd4190f2233a781e885ff36e35 Mon Sep 17 00:00:00 2001 From: jjangminii Date: Sun, 23 Aug 2026 17:11:13 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor(web):=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=A8=B8=20overtime=20=EC=83=81=ED=83=9C=EB=A5=BC=20zustand=20?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EC=96=B4=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sessionStorage를 컴포넌트 로컬 상태로 직접 읽던 방식을 zustand 스토어로 옮겼습니다 - TimerPanel과 FocusSession처럼 이 훅을 각자 호출하는 화면들이 초과시간 상태를 서로 동기화해서 볼 수 있게 했습니다 --- .../hooks/timer/use-timer-overtime.ts | 33 +++----------- .../stores/timer/useTimerOvertimeStore.ts | 23 ++++++++++ apps/timo-web/utils/timer/overtime-storage.ts | 45 +++++++++++++++++++ 3 files changed, 74 insertions(+), 27 deletions(-) create mode 100644 apps/timo-web/stores/timer/useTimerOvertimeStore.ts create mode 100644 apps/timo-web/utils/timer/overtime-storage.ts diff --git a/apps/timo-web/hooks/timer/use-timer-overtime.ts b/apps/timo-web/hooks/timer/use-timer-overtime.ts index 149be211..ad06bd9b 100644 --- a/apps/timo-web/hooks/timer/use-timer-overtime.ts +++ b/apps/timo-web/hooks/timer/use-timer-overtime.ts @@ -1,38 +1,17 @@ "use client"; -import { useEffect, useState } from "react"; - import type { ActiveTimer } from "@/schemas/timer/timer-schema"; -const STORAGE_KEY_PREFIX = "timo:overtime-base:"; - -const readStoredBase = (timerId: number): number | null => { - if (typeof window === "undefined") return null; - - const raw = window.sessionStorage.getItem(`${STORAGE_KEY_PREFIX}${timerId}`); - if (raw === null) return null; - - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : null; -}; +import { useTimerOvertimeStore } from "@/stores/timer/useTimerOvertimeStore"; export const useTimerOvertime = (timer: ActiveTimer | undefined) => { const timerId = timer?.timerId; - const [overtimeBaseSeconds, setOvertimeBaseSeconds] = useState( - null, + const overtimeBaseSeconds = useTimerOvertimeStore((state) => + state.timerId === timerId ? state.baseSeconds : null, + ); + const markOvertimeStart = useTimerOvertimeStore( + (state) => state.markOvertimeStart, ); - - useEffect(() => { - setOvertimeBaseSeconds(timerId ? readStoredBase(timerId) : null); - }, [timerId]); - - const markOvertimeStart = (timerId: number, baseSeconds: number) => { - setOvertimeBaseSeconds(baseSeconds); - window.sessionStorage.setItem( - `${STORAGE_KEY_PREFIX}${timerId}`, - String(baseSeconds), - ); - }; return { overtimeBaseSeconds, markOvertimeStart }; }; diff --git a/apps/timo-web/stores/timer/useTimerOvertimeStore.ts b/apps/timo-web/stores/timer/useTimerOvertimeStore.ts new file mode 100644 index 00000000..c1474816 --- /dev/null +++ b/apps/timo-web/stores/timer/useTimerOvertimeStore.ts @@ -0,0 +1,23 @@ +import { create } from "zustand"; + +import { + getOvertimeBase, + setOvertimeBase, +} from "@/utils/timer/overtime-storage"; + +interface TimerOvertimeState { + timerId: number | null; + baseSeconds: number | null; + markOvertimeStart: (timerId: number, baseSeconds: number) => void; +} + +const stored = getOvertimeBase(); + +export const useTimerOvertimeStore = create((set) => ({ + timerId: stored?.timerId ?? null, + baseSeconds: stored?.baseSeconds ?? null, + markOvertimeStart: (timerId, baseSeconds) => { + setOvertimeBase(timerId, baseSeconds); + set({ timerId, baseSeconds }); + }, +})); diff --git a/apps/timo-web/utils/timer/overtime-storage.ts b/apps/timo-web/utils/timer/overtime-storage.ts new file mode 100644 index 00000000..e28487a6 --- /dev/null +++ b/apps/timo-web/utils/timer/overtime-storage.ts @@ -0,0 +1,45 @@ +const OVERTIME_BASE_KEY = "timo:overtime-base"; + +const isBrowser = typeof window !== "undefined"; + +export interface OvertimeBase { + timerId: number; + baseSeconds: number; +} + +/** + * 진행 중인 타이머의 초과시간 시작 기준값을 sessionStorage에서 가져옵니다. + * + * @returns 저장된 타이머 ID와 기준값(초), 없거나 서버 환경이면 null + */ +export const getOvertimeBase = (): OvertimeBase | null => { + if (!isBrowser) return null; + + const raw = window.sessionStorage.getItem(OVERTIME_BASE_KEY); + if (raw === null) return null; + + try { + const parsed = JSON.parse(raw) as OvertimeBase; + return Number.isFinite(parsed.timerId) && + Number.isFinite(parsed.baseSeconds) + ? parsed + : null; + } catch { + return null; + } +}; + +/** + * 진행 중인 타이머의 초과시간 시작 기준값을 sessionStorage에 저장합니다. + * + * @param timerId - 초과시간이 시작된 타이머 ID + * @param baseSeconds - 초과시간이 시작된 시점의 경과 초 + */ +export const setOvertimeBase = (timerId: number, baseSeconds: number): void => { + if (!isBrowser) return; + + window.sessionStorage.setItem( + OVERTIME_BASE_KEY, + JSON.stringify({ timerId, baseSeconds }), + ); +}; From 89f340817a93df3d32c903619ee71a6f6a5de62a Mon Sep 17 00:00:00 2001 From: jjangminii Date: Sun, 23 Aug 2026 17:11:24 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor(web):=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=A8=B8=20=EC=A7=84=ED=96=89=EB=A5=A0=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=9D=84=20=EA=B3=B5=ED=86=B5=20=ED=9B=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TimerPanel과 useFocusSession에 거의 동일하게 중복돼 있던 progress/overtime/분 단위 변환 계산을 useTimerProgress 훅으로 추출했습니다 --- .../(main)/focus/_hooks/use-focus-session.ts | 45 +++++--------- .../layout/sidebar/time/TimerPanel.tsx | 45 ++++---------- .../hooks/timer/use-timer-progress.ts | 59 +++++++++++++++++++ 3 files changed, 84 insertions(+), 65 deletions(-) create mode 100644 apps/timo-web/hooks/timer/use-timer-progress.ts diff --git a/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts index 6818c827..f365055b 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts +++ b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts @@ -22,8 +22,8 @@ import { convertDateToBadgeText } from "@/app/[locale]/(main)/focus/_utils/date" import { useActiveTimer } from "@/hooks/timer/use-active-timer"; import { useTimerActions } from "@/hooks/timer/use-timer-actions"; import { useTimerOvertime } from "@/hooks/timer/use-timer-overtime"; +import { useTimerProgress } from "@/hooks/timer/use-timer-progress"; import { useTimerQueryInvalidation } from "@/hooks/timer/use-timer-query-invalidation"; -import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; export interface UseFocusSessionOptions { onMutationError: () => void; @@ -219,35 +219,20 @@ export const useFocusSession = ({ const today = new Date(focusView.date); const dateText = convertDateToBadgeText(today); - const plannedSeconds = timer - ? timer.plannedSeconds + timer.extendedSeconds - : (todo?.durationSeconds ?? 0); - const remainingSeconds = timer ? timer.remainingSeconds : plannedSeconds; - const progress = - plannedSeconds > 0 - ? ((plannedSeconds - remainingSeconds) / plannedSeconds) * 100 - : 0; - const isOvertime = overtimeBaseSeconds !== null; - const overtimeTotal = timer - ? timer.plannedSeconds + timer.extendedSeconds - (overtimeBaseSeconds ?? 0) - : 0; - const overtimeProgress = - timer && overtimeBaseSeconds !== null && overtimeTotal > 0 - ? Math.min( - 100, - Math.max( - 0, - ((timer.elapsedSeconds - overtimeBaseSeconds) / overtimeTotal) * - 100, - ), - ) - : 0; - const plannedMinutes = convertDurationToMinutes(plannedSeconds); - // 완료 모달의 "계획"은 연장 시간을 제외한 순수 계획 시간만 보여줘야 한다 - const basePlannedMinutes = convertDurationToMinutes( - timer ? timer.plannedSeconds : (todo?.durationSeconds ?? 0), - ); - const actualMinutes = convertDurationToMinutes(timer?.elapsedSeconds ?? 0); + const { + plannedSeconds, + remainingSeconds, + progress, + isOvertime, + overtimeProgress, + plannedMinutes, + basePlannedMinutes, + actualMinutes, + } = useTimerProgress({ + timer, + overtimeBaseSeconds, + fallbackPlannedSeconds: todo?.durationSeconds ?? 0, + }); return { focusSessionState: { diff --git a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx index afeae0f4..9cbfcb7e 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx @@ -17,9 +17,9 @@ import { import { useActiveTimer } from "@/hooks/timer/use-active-timer"; import { useTimerActions } from "@/hooks/timer/use-timer-actions"; import { useTimerOvertime } from "@/hooks/timer/use-timer-overtime"; +import { useTimerProgress } from "@/hooks/timer/use-timer-progress"; import { useTimerQueryInvalidation } from "@/hooks/timer/use-timer-query-invalidation"; import { formatDateKey } from "@/utils/date/date"; -import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; import { convertDurationToTimeText } from "@/utils/duration/convert-duration-to-time-text"; import { formatDurationLabel } from "@/utils/duration/format-duration-label"; @@ -117,40 +117,15 @@ export const TimerPanel = () => { changeTodoStatus, }); - const plannedSeconds = activeTimer - ? activeTimer.plannedSeconds + activeTimer.extendedSeconds - : 0; - const remainingSeconds = activeTimer ? activeTimer.remainingSeconds : 0; - const progress = - plannedSeconds > 0 - ? ((plannedSeconds - remainingSeconds) / plannedSeconds) * 100 - : 0; - const isOvertime = overtimeBaseSeconds !== null; - const overtimeTotal = activeTimer - ? activeTimer.plannedSeconds + - activeTimer.extendedSeconds - - (overtimeBaseSeconds ?? 0) - : 0; - const overtimeProgress = - activeTimer && overtimeBaseSeconds !== null && overtimeTotal > 0 - ? Math.min( - 100, - Math.max( - 0, - ((activeTimer.elapsedSeconds - overtimeBaseSeconds) / - overtimeTotal) * - 100, - ), - ) - : 0; - const plannedMinutes = convertDurationToMinutes(plannedSeconds); - // 완료 모달의 "계획"은 연장 시간을 제외한 순수 계획 시간만 보여줘야 한다 - const basePlannedMinutes = convertDurationToMinutes( - activeTimer?.plannedSeconds ?? 0, - ); - const actualMinutes = convertDurationToMinutes( - activeTimer?.elapsedSeconds ?? 0, - ); + const { + remainingSeconds, + progress, + isOvertime, + overtimeProgress, + plannedMinutes, + basePlannedMinutes, + actualMinutes, + } = useTimerProgress({ timer: activeTimer, overtimeBaseSeconds }); return (
diff --git a/apps/timo-web/hooks/timer/use-timer-progress.ts b/apps/timo-web/hooks/timer/use-timer-progress.ts new file mode 100644 index 00000000..7d0251c8 --- /dev/null +++ b/apps/timo-web/hooks/timer/use-timer-progress.ts @@ -0,0 +1,59 @@ +import type { ActiveTimer } from "@/schemas/timer/timer-schema"; + +import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; + +export interface UseTimerProgressOptions { + timer: ActiveTimer | undefined; + overtimeBaseSeconds: number | null; + /** 활성 타이머가 없을 때 사용할 계획 시간(초). 기본값 0 */ + fallbackPlannedSeconds?: number; +} + +export const useTimerProgress = ({ + timer, + overtimeBaseSeconds, + fallbackPlannedSeconds = 0, +}: UseTimerProgressOptions) => { + const plannedSeconds = timer + ? timer.plannedSeconds + timer.extendedSeconds + : fallbackPlannedSeconds; + const remainingSeconds = timer ? timer.remainingSeconds : plannedSeconds; + const progress = + plannedSeconds > 0 + ? ((plannedSeconds - remainingSeconds) / plannedSeconds) * 100 + : 0; + + const isOvertime = overtimeBaseSeconds !== null; + const overtimeTotal = timer + ? timer.plannedSeconds + timer.extendedSeconds - (overtimeBaseSeconds ?? 0) + : 0; + const overtimeProgress = + timer && overtimeBaseSeconds !== null && overtimeTotal > 0 + ? Math.min( + 100, + Math.max( + 0, + ((timer.elapsedSeconds - overtimeBaseSeconds) / overtimeTotal) * + 100, + ), + ) + : 0; + + const plannedMinutes = convertDurationToMinutes(plannedSeconds); + // 완료 모달의 "계획"은 연장 시간을 제외한 순수 계획 시간만 보여줘야 한다 + const basePlannedMinutes = convertDurationToMinutes( + timer ? timer.plannedSeconds : fallbackPlannedSeconds, + ); + const actualMinutes = convertDurationToMinutes(timer?.elapsedSeconds ?? 0); + + return { + plannedSeconds, + remainingSeconds, + progress, + isOvertime, + overtimeProgress, + plannedMinutes, + basePlannedMinutes, + actualMinutes, + }; +}; From d8d468d6e14527b7faf3ed91041d5d0c90314c16 Mon Sep 17 00:00:00 2001 From: jjangminii Date: Sun, 23 Aug 2026 17:42:51 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix(web):=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EC=A4=91=20=EC=95=A1=EC=85=98=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=ED=86=B5=EA=B3=84=20=EC=BF=BC=EB=A6=AC=20=EB=AC=B4?= =?UTF-8?q?=ED=9A=A8=ED=99=94=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - invalidateTimerProgress(일시정지/재개/연장)에서 빠졌던 invalidateStatistics 호출을 복원했습니다 - use-focus-session.ts가 같은 무효화 로직을 따로 손으로 하고 있어서 invalidateTimerProgress를 쓰도록 함께 정리했습니다 --- .../(main)/focus/_hooks/use-focus-session.ts | 13 ++++--------- .../hooks/timer/use-timer-query-invalidation.ts | 1 + 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts index f365055b..892ed5e0 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts +++ b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts @@ -48,6 +48,7 @@ export const useFocusSession = ({ invalidateTodayView, invalidateFocusTodo, invalidateStatistics, + invalidateTimerProgress, } = useTimerQueryInvalidation(); const invalidateTodoDetail = () => { const todo = focusView.todo; @@ -60,9 +61,7 @@ export const useFocusSession = ({ const { mutate: startTimer } = useStartTimer({ mutation: { onSuccess: () => { - invalidateActiveTimer(); - invalidateHomeView(); - invalidateTimeBoxes(); + invalidateTimerProgress(); }, onError: onMutationError, }, @@ -70,9 +69,7 @@ export const useFocusSession = ({ const { mutate: changeStatus } = useChangeStatus({ mutation: { onSuccess: () => { - invalidateActiveTimer(); - invalidateHomeView(); - invalidateTimeBoxes(); + invalidateTimerProgress(); }, onError: onMutationError, }, @@ -80,9 +77,7 @@ export const useFocusSession = ({ const { mutate: extendTimer } = useExtendTimer({ mutation: { onSuccess: () => { - invalidateActiveTimer(); - invalidateHomeView(); - invalidateTimeBoxes(); + invalidateTimerProgress(); }, onError: onMutationError, }, diff --git a/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts b/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts index 04c42fe1..d624f81c 100644 --- a/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts +++ b/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts @@ -36,6 +36,7 @@ export const useTimerQueryInvalidation = () => { invalidateActiveTimer(); invalidateHomeView(); invalidateTimeBoxes(); + invalidateStatistics(); }; /** 완료/중지처럼 타이머가 종료되는 액션 이후 무효화 */ From ed45072d53297234ed08191247fa4d8db349f63a Mon Sep 17 00:00:00 2001 From: jjangminii Date: Tue, 25 Aug 2026 14:10:05 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor(web):=20use-timer-progress?= =?UTF-8?q?=EB=A5=BC=20utils=EB=A1=9C=20=EC=9D=B4=EB=8F=99=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20getTimerProgress=EB=A1=9C=20=EA=B0=9C=EB=AA=85=20(#?= =?UTF-8?q?279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - React 훅을 쓰지 않는 순수 계산 로직이라 hooks/의 use 접두사가 맞지 않다는 리뷰 피드백을 반영했습니다 - utils/timer/get-timer-progress.ts로 옮기고 getTimerProgress로 개명했습니다 - utils 컨벤션에 맞춰 JSDoc(설명/@param/@returns)을 추가했습니다 --- .../hooks/timer/use-timer-progress.ts | 59 ------------------- 1 file changed, 59 deletions(-) delete mode 100644 apps/timo-web/hooks/timer/use-timer-progress.ts diff --git a/apps/timo-web/hooks/timer/use-timer-progress.ts b/apps/timo-web/hooks/timer/use-timer-progress.ts deleted file mode 100644 index 7d0251c8..00000000 --- a/apps/timo-web/hooks/timer/use-timer-progress.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { ActiveTimer } from "@/schemas/timer/timer-schema"; - -import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; - -export interface UseTimerProgressOptions { - timer: ActiveTimer | undefined; - overtimeBaseSeconds: number | null; - /** 활성 타이머가 없을 때 사용할 계획 시간(초). 기본값 0 */ - fallbackPlannedSeconds?: number; -} - -export const useTimerProgress = ({ - timer, - overtimeBaseSeconds, - fallbackPlannedSeconds = 0, -}: UseTimerProgressOptions) => { - const plannedSeconds = timer - ? timer.plannedSeconds + timer.extendedSeconds - : fallbackPlannedSeconds; - const remainingSeconds = timer ? timer.remainingSeconds : plannedSeconds; - const progress = - plannedSeconds > 0 - ? ((plannedSeconds - remainingSeconds) / plannedSeconds) * 100 - : 0; - - const isOvertime = overtimeBaseSeconds !== null; - const overtimeTotal = timer - ? timer.plannedSeconds + timer.extendedSeconds - (overtimeBaseSeconds ?? 0) - : 0; - const overtimeProgress = - timer && overtimeBaseSeconds !== null && overtimeTotal > 0 - ? Math.min( - 100, - Math.max( - 0, - ((timer.elapsedSeconds - overtimeBaseSeconds) / overtimeTotal) * - 100, - ), - ) - : 0; - - const plannedMinutes = convertDurationToMinutes(plannedSeconds); - // 완료 모달의 "계획"은 연장 시간을 제외한 순수 계획 시간만 보여줘야 한다 - const basePlannedMinutes = convertDurationToMinutes( - timer ? timer.plannedSeconds : fallbackPlannedSeconds, - ); - const actualMinutes = convertDurationToMinutes(timer?.elapsedSeconds ?? 0); - - return { - plannedSeconds, - remainingSeconds, - progress, - isOvertime, - overtimeProgress, - plannedMinutes, - basePlannedMinutes, - actualMinutes, - }; -}; From b2967e214258a5395aab89834efa8eb8d40f566d Mon Sep 17 00:00:00 2001 From: jjangminii Date: Tue, 25 Aug 2026 14:11:06 +0900 Subject: [PATCH 6/9] =?UTF-8?q?refactor(web):=20getTimerProgress=EB=A1=9C?= =?UTF-8?q?=20=EA=B0=9C=EB=AA=85=ED=95=9C=20=ED=8C=8C=EC=9D=BC=EA=B3=BC=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EB=B6=80=20=EB=B0=98=EC=98=81=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 앞 커밋에서 새 파일 추가와 호출부 수정이 스테이징 누락으로 빠졌던 부분을 마저 커밋했습니다 --- .../(main)/focus/_hooks/use-focus-session.ts | 4 +- .../layout/sidebar/time/TimerPanel.tsx | 4 +- .../utils/timer/get-timer-progress.ts | 67 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 apps/timo-web/utils/timer/get-timer-progress.ts diff --git a/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts index 892ed5e0..2bff914a 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts +++ b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts @@ -22,8 +22,8 @@ import { convertDateToBadgeText } from "@/app/[locale]/(main)/focus/_utils/date" import { useActiveTimer } from "@/hooks/timer/use-active-timer"; import { useTimerActions } from "@/hooks/timer/use-timer-actions"; import { useTimerOvertime } from "@/hooks/timer/use-timer-overtime"; -import { useTimerProgress } from "@/hooks/timer/use-timer-progress"; import { useTimerQueryInvalidation } from "@/hooks/timer/use-timer-query-invalidation"; +import { getTimerProgress } from "@/utils/timer/get-timer-progress"; export interface UseFocusSessionOptions { onMutationError: () => void; @@ -223,7 +223,7 @@ export const useFocusSession = ({ plannedMinutes, basePlannedMinutes, actualMinutes, - } = useTimerProgress({ + } = getTimerProgress({ timer, overtimeBaseSeconds, fallbackPlannedSeconds: todo?.durationSeconds ?? 0, diff --git a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx index 9cbfcb7e..39847b94 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx @@ -17,11 +17,11 @@ import { import { useActiveTimer } from "@/hooks/timer/use-active-timer"; import { useTimerActions } from "@/hooks/timer/use-timer-actions"; import { useTimerOvertime } from "@/hooks/timer/use-timer-overtime"; -import { useTimerProgress } from "@/hooks/timer/use-timer-progress"; import { useTimerQueryInvalidation } from "@/hooks/timer/use-timer-query-invalidation"; import { formatDateKey } from "@/utils/date/date"; import { convertDurationToTimeText } from "@/utils/duration/convert-duration-to-time-text"; import { formatDurationLabel } from "@/utils/duration/format-duration-label"; +import { getTimerProgress } from "@/utils/timer/get-timer-progress"; export const TimerPanel = () => { const [feedbackText, setFeedbackText] = useState(); @@ -125,7 +125,7 @@ export const TimerPanel = () => { plannedMinutes, basePlannedMinutes, actualMinutes, - } = useTimerProgress({ timer: activeTimer, overtimeBaseSeconds }); + } = getTimerProgress({ timer: activeTimer, overtimeBaseSeconds }); return (
diff --git a/apps/timo-web/utils/timer/get-timer-progress.ts b/apps/timo-web/utils/timer/get-timer-progress.ts new file mode 100644 index 00000000..7ad81308 --- /dev/null +++ b/apps/timo-web/utils/timer/get-timer-progress.ts @@ -0,0 +1,67 @@ +import type { ActiveTimer } from "@/schemas/timer/timer-schema"; + +import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; + +export interface GetTimerProgressOptions { + timer: ActiveTimer | undefined; + overtimeBaseSeconds: number | null; + /** 활성 타이머가 없을 때 사용할 계획 시간(초). 기본값 0 */ + fallbackPlannedSeconds?: number; +} + +/** + * 활성 타이머와 초과시간 기준값으로부터 화면에 필요한 진행률 파생값을 계산합니다. + * + * @param options.timer - 현재 활성 타이머, 없으면 fallbackPlannedSeconds를 계획 시간으로 사용 + * @param options.overtimeBaseSeconds - 초과시간이 시작된 시점의 경과 초, 초과시간이 아니면 null + * @param options.fallbackPlannedSeconds - 활성 타이머가 없을 때 사용할 계획 시간(초) + * @returns 진행률(%), 초과시간 진행률(%), 분 단위로 변환된 계획/실제 시간 등 화면에 필요한 값들 + */ +export const getTimerProgress = ({ + timer, + overtimeBaseSeconds, + fallbackPlannedSeconds = 0, +}: GetTimerProgressOptions) => { + const plannedSeconds = timer + ? timer.plannedSeconds + timer.extendedSeconds + : fallbackPlannedSeconds; + const remainingSeconds = timer ? timer.remainingSeconds : plannedSeconds; + const progress = + plannedSeconds > 0 + ? ((plannedSeconds - remainingSeconds) / plannedSeconds) * 100 + : 0; + + const isOvertime = overtimeBaseSeconds !== null; + const overtimeTotal = timer + ? timer.plannedSeconds + timer.extendedSeconds - (overtimeBaseSeconds ?? 0) + : 0; + const overtimeProgress = + timer && overtimeBaseSeconds !== null && overtimeTotal > 0 + ? Math.min( + 100, + Math.max( + 0, + ((timer.elapsedSeconds - overtimeBaseSeconds) / overtimeTotal) * + 100, + ), + ) + : 0; + + const plannedMinutes = convertDurationToMinutes(plannedSeconds); + // 완료 모달의 "계획"은 연장 시간을 제외한 순수 계획 시간만 보여줘야 한다 + const basePlannedMinutes = convertDurationToMinutes( + timer ? timer.plannedSeconds : fallbackPlannedSeconds, + ); + const actualMinutes = convertDurationToMinutes(timer?.elapsedSeconds ?? 0); + + return { + plannedSeconds, + remainingSeconds, + progress, + isOvertime, + overtimeProgress, + plannedMinutes, + basePlannedMinutes, + actualMinutes, + }; +}; From e732db0bb371167225469679ad6f875012dedcd3 Mon Sep 17 00:00:00 2001 From: jjangminii Date: Tue, 25 Aug 2026 14:57:25 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor(web):=20overtime=20storage=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=EC=9D=84=20unknown=20=ED=9B=84=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EA=B0=80=EB=93=9C=EB=A1=9C=20=EC=A2=81=ED=9E=88?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JSON.parse(raw)를 곧바로 as OvertimeBase로 단언하던 걸 unknown으로 받아 isOvertimeBase 타입가드로 좁히도록 바꿨습니다 - sessionStorage에서 읽은 외부 입력은 검증 전에 타입을 확정하면 안 된다는 리뷰 피드백을 반영했습니다 --- apps/timo-web/utils/timer/overtime-storage.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/timo-web/utils/timer/overtime-storage.ts b/apps/timo-web/utils/timer/overtime-storage.ts index e28487a6..ab128795 100644 --- a/apps/timo-web/utils/timer/overtime-storage.ts +++ b/apps/timo-web/utils/timer/overtime-storage.ts @@ -7,6 +7,13 @@ export interface OvertimeBase { baseSeconds: number; } +const isOvertimeBase = (value: unknown): value is OvertimeBase => { + if (typeof value !== "object" || value === null) return false; + + const { timerId, baseSeconds } = value as Record; + return Number.isFinite(timerId) && Number.isFinite(baseSeconds); +}; + /** * 진행 중인 타이머의 초과시간 시작 기준값을 sessionStorage에서 가져옵니다. * @@ -19,11 +26,8 @@ export const getOvertimeBase = (): OvertimeBase | null => { if (raw === null) return null; try { - const parsed = JSON.parse(raw) as OvertimeBase; - return Number.isFinite(parsed.timerId) && - Number.isFinite(parsed.baseSeconds) - ? parsed - : null; + const parsed: unknown = JSON.parse(raw); + return isOvertimeBase(parsed) ? parsed : null; } catch { return null; } From 84b8185b6b3aa81e317a30140fcb48edd7af12b4 Mon Sep 17 00:00:00 2001 From: jjangminii Date: Tue, 25 Aug 2026 15:18:45 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor(web):=20use-focus-session=EB=8F=84?= =?UTF-8?q?=20invalidateTimerFinish=20=EA=B3=B5=EC=9C=A0=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - completeTimer/stopTimer의 무효화 목록을 손으로 나열하던 걸 공유 헬퍼 invalidateTimerFinish(todoId)로 교체했습니다 - invalidateTodoDetail(todoId)는 date 파라미터가 없지만 React Query v5의 invalidateQueries는 기본이 prefix 매칭이라 date-scoped 캐시까지 함께 무효화됩니다 - 더는 쓰이지 않는 invalidateActiveTimer/invalidateHomeView 구조분해도 정리했습니다 --- .../(main)/focus/_hooks/use-focus-session.ts | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts index 2bff914a..8711fcc3 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts +++ b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts @@ -42,13 +42,12 @@ export const useFocusSession = ({ const { data: activeTimer } = useActiveTimer(); const { - invalidateActiveTimer, - invalidateHomeView, invalidateTimeBoxes, invalidateTodayView, invalidateFocusTodo, invalidateStatistics, invalidateTimerProgress, + invalidateTimerFinish, } = useTimerQueryInvalidation(); const invalidateTodoDetail = () => { const todo = focusView.todo; @@ -86,13 +85,7 @@ export const useFocusSession = ({ mutation: { onSuccess: (response) => { onFeedback(response.data?.aiFeedback ?? undefined); - invalidateActiveTimer(); - invalidateFocusTodo(); - invalidateHomeView(); - invalidateTimeBoxes(); - invalidateTodayView(); - invalidateTodoDetail(); - invalidateStatistics(); + invalidateTimerFinish(response.data?.todoId); }, onError: onMutationError, }, @@ -101,13 +94,7 @@ export const useFocusSession = ({ mutation: { onSuccess: (response) => { onFeedback(response.data?.aiFeedback ?? undefined); - invalidateActiveTimer(); - invalidateFocusTodo(); - invalidateHomeView(); - invalidateTimeBoxes(); - invalidateTodayView(); - invalidateTodoDetail(); - invalidateStatistics(); + invalidateTimerFinish(response.data?.todoId); }, onError: onMutationError, }, From 848ce9eb9372e75506f6bf72026b52901479544e Mon Sep 17 00:00:00 2001 From: jjangminii Date: Tue, 25 Aug 2026 16:13:17 +0900 Subject: [PATCH 9/9] =?UTF-8?q?docs(web):=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EB=AC=B4=ED=9A=A8=ED=99=94=20=ED=97=AC=ED=8D=BC=EC=97=90=20JSD?= =?UTF-8?q?oc=20=EC=B6=94=EA=B0=80=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - invalidateTimerProgress/invalidateTimerFinish에 여러 줄 JSDoc과 invalidateTimerFinish의 todoId에 @param을 추가했습니다 - 동작 변화는 없고 문서만 보강했습니다 --- .../hooks/timer/use-timer-query-invalidation.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts b/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts index d624f81c..940d93c7 100644 --- a/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts +++ b/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts @@ -31,7 +31,9 @@ export const useTimerQueryInvalidation = () => { queryKey: getGetTodoDetailQueryKey(todoId), }); - /** 일시정지/재개/연장처럼 타이머가 계속 진행 중인 액션 이후 무효화 */ + /** + * 일시정지/재개/연장처럼 타이머가 계속 진행 중인 액션 이후 무효화 + */ const invalidateTimerProgress = () => { invalidateActiveTimer(); invalidateHomeView(); @@ -39,7 +41,11 @@ export const useTimerQueryInvalidation = () => { invalidateStatistics(); }; - /** 완료/중지처럼 타이머가 종료되는 액션 이후 무효화 */ + /** + * 완료/중지처럼 타이머가 종료되는 액션 이후 무효화 + * + * @param todoId - 투두 상세 쿼리를 무효화할 투두 ID. 없으면 투두 상세는 건너뜁니다. + */ const invalidateTimerFinish = (todoId?: number) => { invalidateActiveTimer(); invalidateHomeView();