From 4c8a04fe072b64cd43c30066a4464faaa11c39b7 Mon Sep 17 00:00:00 2001 From: jjangminii Date: Sun, 23 Aug 2026 16:44:41 +0900 Subject: [PATCH 01/11] =?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 02/11] =?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 03/11] =?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 04/11] =?UTF-8?q?fix(web):=20=ED=83=80=EC=9D=B4=EB=A8=B8?= =?UTF-8?q?=20=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 05/11] =?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 06/11] =?UTF-8?q?refactor(web):=20getTimerProgress?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=9C=EB=AA=85=ED=95=9C=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EA=B3=BC=20=ED=98=B8=EC=B6=9C=EB=B6=80=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?(#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 07/11] =?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 08/11] =?UTF-8?q?refactor(web):=20use-focus-session?= =?UTF-8?q?=EB=8F=84=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 09/11] =?UTF-8?q?docs(web):=20=ED=83=80=EC=9D=B4=EB=A8=B8?= =?UTF-8?q?=20=EB=AC=B4=ED=9A=A8=ED=99=94=20=ED=97=AC=ED=8D=BC=EC=97=90=20?= =?UTF-8?q?JSDoc=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(); From f21ad3a35f5bbbe9c1471748ce02d68bb6232376 Mon Sep 17 00:00:00 2001 From: jjangminii Date: Mon, 7 Sep 2026 15:48:23 +0900 Subject: [PATCH 10/11] =?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=20=EB=AC=B4=ED=9A=A8=ED=99=94?= =?UTF-8?q?=EC=97=90=20=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EA=B0=9C=EB=B3=84=20=ED=9B=85=EC=9D=98=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=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 - invalidateTimerProgress에 includeFocus, todoId 옵션을 추가했습니다 - 홈/투데이 훅에서 mutation.onSuccess로 흩어져 있던 무효화 로직을 헬퍼 호출로 통일했습니다 - 타이머 종료 시 invalidateTimerFinish를 사용하도록 변경했습니다 --- .../home/_hooks/use-home-todos-by-date.ts | 43 ++++++------------ .../today/_hooks/useTodayTodoList.ts | 45 +++++++------------ .../timer/use-timer-query-invalidation.ts | 16 ++++++- 3 files changed, 44 insertions(+), 60 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 bbe78b73..64ae449e 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 @@ -56,28 +56,15 @@ export const useHomeTodosByDate = ( invalidateHomeView, invalidateStatistics, invalidateTimerProgress, + invalidateTimerFinish, invalidateTimeBoxes, invalidateFocusTodo, } = useTimerQueryInvalidation(); const { mutate: startTimer, isPending: isStartTimerPending } = - useStartTimer({ - mutation: { - onSuccess: () => { - invalidateTimerProgress(); - invalidateFocusTodo(); - }, - }, - }); + useStartTimer(); const { mutate: changeStatus, isPending: isChangeStatusPending } = - useChangeStatus({ - mutation: { - onSuccess: () => { - invalidateTimerProgress(); - invalidateFocusTodo(); - }, - }, - }); + useChangeStatus(); const isTimerActionPending = isStartTimerPending || isChangeStatusPending || isActiveTimerFetching; @@ -153,22 +140,16 @@ export const useHomeTodosByDate = ( { onSuccess: (response) => { onStopFeedback(response.data?.aiFeedback ?? undefined); - invalidateTimerProgress(); + invalidateTimerFinish(todoId); updateTodo(dateKey, todoId, (todo) => ({ ...todo, completed: true, })); - changeTodoStatus( - { todoId, data: { isCompleted: true, date: dateKey } }, - { - onSuccess: () => { - invalidateHomeAndFocus(); - invalidateTodoDetail(dateKey, todoId); - invalidateStatistics(); - }, - }, - ); + changeTodoStatus({ + todoId, + data: { isCompleted: true, date: dateKey }, + }); }, }, ); @@ -197,7 +178,10 @@ export const useHomeTodosByDate = ( action: activeTimer.status === "RUNNING" ? "PAUSE" : "RESUME", }, }, - { onSuccess: () => invalidateTodoDetail(dateKey, todoId) }, + { + onSuccess: () => + invalidateTimerProgress({ includeFocus: true, todoId }), + }, ); return; } @@ -211,7 +195,8 @@ export const useHomeTodosByDate = ( startTimer( { todoId, params: { date: dateKey } }, { - onSuccess: () => invalidateTodoDetail(dateKey, todoId), + onSuccess: () => + invalidateTimerProgress({ includeFocus: true, todoId }), onError: (error: ApiError) => { onPlayError(error.message); }, 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 4958c916..da7d6e7c 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 @@ -49,30 +49,18 @@ export const useTodayTodoList = ( const { mutate: stopTimer } = useStopTimer(); const { invalidateTimerProgress, + invalidateTimerFinish, invalidateTimeBoxes, invalidateTodayView, invalidateFocusTodo, invalidateStatistics, } = useTimerQueryInvalidation(); - const { mutate: startTimer, isPending: isStartTimerPending } = useStartTimer({ - mutation: { - onSuccess: () => { - invalidateTimerProgress(); - invalidateFocusTodo(); - }, - }, - }); + const { mutate: startTimer, isPending: isStartTimerPending } = + useStartTimer(); const { mutate: changeStatus, isPending: isChangeStatusPending } = - useChangeStatus({ - mutation: { - onSuccess: () => { - invalidateTimerProgress(); - invalidateFocusTodo(); - }, - }, - }); + useChangeStatus(); const isTimerActionPending = isStartTimerPending || isChangeStatusPending || isActiveTimerFetching; @@ -141,20 +129,13 @@ export const useTodayTodoList = ( { onSuccess: (response) => { onStopFeedback(response.data?.aiFeedback ?? undefined); - invalidateTimerProgress(); + invalidateTimerFinish(todoId); updateTodo(todoId, (todo) => ({ ...todo, completed: true })); - changeTodoStatus( - { todoId, data: { isCompleted: true, date: dateKey } }, - { - onSuccess: () => { - invalidateTodayView(); - invalidateTodoDetail(todoId, dateKey); - invalidateFocusTodo(); - invalidateStatistics(); - }, - }, - ); + changeTodoStatus({ + todoId, + data: { isCompleted: true, date: dateKey }, + }); }, }, ); @@ -185,7 +166,10 @@ export const useTodayTodoList = ( action: activeTimer.status === "RUNNING" ? "PAUSE" : "RESUME", }, }, - { onSuccess: () => invalidateTodoDetail(todoId, dateKey) }, + { + onSuccess: () => + invalidateTimerProgress({ includeFocus: true, todoId }), + }, ); return; } @@ -199,7 +183,8 @@ export const useTodayTodoList = ( startTimer( { todoId, params: { date: dateKey } }, { - onSuccess: () => invalidateTodoDetail(todoId, dateKey), + onSuccess: () => + invalidateTimerProgress({ includeFocus: true, todoId }), onError: (error: ErrorType) => { onPlayError(error.response?.data.message); }, 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 940d93c7..5a22c804 100644 --- a/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts +++ b/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts @@ -12,6 +12,13 @@ import { getGetActiveTimerQueryKey } from "@/api/generated/endpoints/timer/timer import { getGetTodoDetailQueryKey } from "@/api/generated/endpoints/todo/todo"; import { useStatisticsQueryInvalidation } from "@/hooks/statistics/use-statistics-query-invalidation"; +export interface InvalidateTimerProgressOptions { + /** 포커스 화면 상세도 함께 무효화할지 여부 */ + includeFocus?: boolean; + /** 함께 무효화할 투두 상세 쿼리의 ID */ + todoId?: number; +} + export const useTimerQueryInvalidation = () => { const queryClient = useQueryClient(); const { invalidateStatistics } = useStatisticsQueryInvalidation(); @@ -33,12 +40,19 @@ export const useTimerQueryInvalidation = () => { /** * 일시정지/재개/연장처럼 타이머가 계속 진행 중인 액션 이후 무효화 + * + * @param options.includeFocus - 포커스 화면 상세도 함께 무효화 + * @param options.todoId - 함께 무효화할 투두 상세 쿼리의 ID */ - const invalidateTimerProgress = () => { + const invalidateTimerProgress = ( + options: InvalidateTimerProgressOptions = {}, + ) => { invalidateActiveTimer(); invalidateHomeView(); invalidateTimeBoxes(); invalidateStatistics(); + if (options.includeFocus) invalidateFocusTodo(); + if (options.todoId) invalidateTodoDetail(options.todoId); }; /** From 850b92ef21bd5a4bd429c99afe9c52a6f6f8ec8d Mon Sep 17 00:00:00 2001 From: jjangminii Date: Fri, 11 Sep 2026 17:01:11 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix(web):=20=ED=88=AC=EB=91=90=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=B2=98=EB=A6=AC=20PATCH=20=EC=9D=B4?= =?UTF-8?q?=ED=9B=84=20=EC=9E=AC=EA=B2=80=EC=A6=9D=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=8A=A4=20=EC=BB=A8=EB=94=94=EC=85=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - changeTodoStatus의 무효화 로직을 mutation 정의 시점 onSuccess로 옮겨 어떤 호출부에서도 PATCH 완료 후 항상 재검증하도록 했습니다 - 타이머 종료 시 invalidateTimerFinish만 실행되고 투두 완료 PATCH 성공 이후 재검증이 없어 완료 상태가 화면에 반영되지 않을 수 있던 레이스 컨디션을 수정했습니다 - invalidateTodoDetail이 date를 옵션으로 받도록 확장하고 화면별로 중복 구현돼 있던 로컬 무효화 래퍼를 제거해 focus/TimerPanel과 구조를 통일했습니다 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GWUhaUE5dxUTjmjKe4c3LV --- .../home/_hooks/use-home-todos-by-date.ts | 43 ++++++++----------- .../today/_hooks/useTodayTodoList.ts | 33 ++++++-------- .../timer/use-timer-query-invalidation.ts | 4 +- 3 files changed, 35 insertions(+), 45 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 cc5e1fd9..f013b120 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 @@ -1,6 +1,5 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import type { HomeViewDay } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type"; @@ -16,7 +15,6 @@ import { useStopTimer, } from "@/generated/endpoints/timer/timer"; import { - getGetTodoDetailQueryKey, useChangeSubtaskStatus, useChangeTodoStatus, useReorderTodo, @@ -45,13 +43,8 @@ export const useHomeTodosByDate = ( ) => { const [todosByDate, setTodosByDate] = useState>({}); const openTimerPanel = useTimeSidebarStore((state) => state.openTimerPanel); - const queryClient = useQueryClient(); const { data: activeTimer, isFetching: isActiveTimerFetching } = useActiveTimer(); - const { mutate: changeTodoStatus } = useChangeTodoStatus(); - const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); - const { mutate: reorderTodo } = useReorderTodo(); - const { mutate: stopTimer } = useStopTimer(); const { invalidateHomeView, invalidateStatistics, @@ -59,7 +52,25 @@ export const useHomeTodosByDate = ( invalidateTimerFinish, invalidateTimeBoxes, invalidateFocusTodo, + invalidateTodoDetail, } = useTimerQueryInvalidation(); + const invalidateHomeAndFocus = () => { + invalidateHomeView(); + invalidateFocusTodo(); + }; + const { mutate: changeTodoStatus } = useChangeTodoStatus({ + mutation: { + onSuccess: (_data, variables) => { + invalidateHomeAndFocus(); + invalidateTimeBoxes(); + invalidateStatistics(); + invalidateTodoDetail(variables.todoId, variables.data.date); + }, + }, + }); + const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); + const { mutate: reorderTodo } = useReorderTodo(); + const { mutate: stopTimer } = useStopTimer(); const { mutate: startTimer, isPending: isStartTimerPending } = useStartTimer(); @@ -88,16 +99,6 @@ export const useHomeTodosByDate = ( })); }; - const invalidateHomeAndFocus = () => { - invalidateHomeView(); - invalidateFocusTodo(); - }; - const invalidateTodoDetail = (dateKey: string, todoId: number) => { - queryClient.invalidateQueries({ - queryKey: getGetTodoDetailQueryKey(todoId, { date: dateKey }), - }); - }; - const handleToggleCompleted = ( dateKey: string, todoId: number, @@ -118,12 +119,6 @@ export const useHomeTodosByDate = ( changeTodoStatus( { todoId, data: { isCompleted: completed, date: dateKey } }, { - onSuccess: () => { - invalidateHomeAndFocus(); - invalidateTimeBoxes(); - invalidateTodoDetail(dateKey, todoId); - invalidateStatistics(); - }, onError: (error: ErrorType) => { setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); onUpdateError(error.response?.data.message); @@ -229,7 +224,7 @@ export const useHomeTodosByDate = ( onSuccess: () => { invalidateHomeAndFocus(); invalidateTimeBoxes(); - invalidateTodoDetail(dateKey, todoId); + invalidateTodoDetail(todoId, dateKey); invalidateStatistics(); }, onError: (error: ErrorType) => { 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 ece4d72b..240eee50 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 @@ -1,6 +1,5 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import type { TodayTodo } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_types/today-type"; @@ -13,7 +12,6 @@ import { useStopTimer, } from "@/generated/endpoints/timer/timer"; import { - getGetTodoDetailQueryKey, useChangeSubtaskStatus, useChangeTodoStatus, } from "@/generated/endpoints/todo/todo"; @@ -41,12 +39,8 @@ export const useTodayTodoList = ( ) => { const [todos, setTodos] = useState(initialTodos); const openTimerPanel = useTimeSidebarStore((state) => state.openTimerPanel); - const queryClient = useQueryClient(); const { data: activeTimer, isFetching: isActiveTimerFetching } = useActiveTimer(); - const { mutate: changeTodoStatus } = useChangeTodoStatus(); - const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); - const { mutate: stopTimer } = useStopTimer(); const { invalidateTimerProgress, invalidateTimerFinish, @@ -54,7 +48,21 @@ export const useTodayTodoList = ( invalidateTodayView, invalidateFocusTodo, invalidateStatistics, + invalidateTodoDetail, } = useTimerQueryInvalidation(); + const { mutate: changeTodoStatus } = useChangeTodoStatus({ + mutation: { + onSuccess: (_data, variables) => { + invalidateTodayView(); + invalidateTimeBoxes(); + invalidateFocusTodo(); + invalidateStatistics(); + invalidateTodoDetail(variables.todoId, variables.data.date); + }, + }, + }); + const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); + const { mutate: stopTimer } = useStopTimer(); const { mutate: startTimer, isPending: isStartTimerPending } = useStartTimer(); @@ -78,12 +86,6 @@ export const useTodayTodoList = ( ); }; - const invalidateTodoDetail = (todoId: number, dateKey: string) => { - queryClient.invalidateQueries({ - queryKey: getGetTodoDetailQueryKey(todoId, { date: dateKey }), - }); - }; - const handleToggleCompleted = (todoId: number, completed: boolean) => { const dateKey = todos.find((todo) => todo.todoId === todoId)?.date; if (!dateKey) return; @@ -103,13 +105,6 @@ export const useTodayTodoList = ( changeTodoStatus( { todoId, data: { isCompleted: completed, date: dateKey } }, { - onSuccess: () => { - invalidateTodayView(); - invalidateTimeBoxes(); - invalidateTodoDetail(todoId, dateKey); - invalidateFocusTodo(); - invalidateStatistics(); - }, onError: (error: ErrorType) => { setTodos(previous); onUpdateError(error.response?.data.message); 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 65f2b746..28763e0e 100644 --- a/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts +++ b/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts @@ -33,9 +33,9 @@ export const useTimerQueryInvalidation = () => { queryClient.invalidateQueries({ queryKey: getGetTodayQueryKey() }); const invalidateFocusTodo = () => queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); - const invalidateTodoDetail = (todoId: number) => + const invalidateTodoDetail = (todoId: number, date?: string) => queryClient.invalidateQueries({ - queryKey: getGetTodoDetailQueryKey(todoId), + queryKey: getGetTodoDetailQueryKey(todoId, date ? { date } : undefined), }); /**