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/app/[locale]/(main)/focus/_hooks/use-focus-session.ts b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts index 6818c827..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 @@ -23,7 +23,7 @@ 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 { useTimerQueryInvalidation } from "@/hooks/timer/use-timer-query-invalidation"; -import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; +import { getTimerProgress } from "@/utils/timer/get-timer-progress"; export interface UseFocusSessionOptions { onMutationError: () => void; @@ -42,12 +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; @@ -60,9 +60,7 @@ export const useFocusSession = ({ const { mutate: startTimer } = useStartTimer({ mutation: { onSuccess: () => { - invalidateActiveTimer(); - invalidateHomeView(); - invalidateTimeBoxes(); + invalidateTimerProgress(); }, onError: onMutationError, }, @@ -70,9 +68,7 @@ export const useFocusSession = ({ const { mutate: changeStatus } = useChangeStatus({ mutation: { onSuccess: () => { - invalidateActiveTimer(); - invalidateHomeView(); - invalidateTimeBoxes(); + invalidateTimerProgress(); }, onError: onMutationError, }, @@ -80,9 +76,7 @@ export const useFocusSession = ({ const { mutate: extendTimer } = useExtendTimer({ mutation: { onSuccess: () => { - invalidateActiveTimer(); - invalidateHomeView(); - invalidateTimeBoxes(); + invalidateTimerProgress(); }, onError: onMutationError, }, @@ -91,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, }, @@ -106,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, }, @@ -219,35 +201,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, + } = getTimerProgress({ + 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 2f4bc03d..39847b94 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx @@ -19,9 +19,9 @@ import { useTimerActions } from "@/hooks/timer/use-timer-actions"; import { useTimerOvertime } from "@/hooks/timer/use-timer-overtime"; 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"; +import { getTimerProgress } from "@/utils/timer/get-timer-progress"; export const TimerPanel = () => { const [feedbackText, setFeedbackText] = useState(); @@ -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); }, }, }); @@ -130,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, + } = getTimerProgress({ timer: activeTimer, overtimeBaseSeconds }); return (
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/hooks/timer/use-timer-query-invalidation.ts b/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts index e7c0cd05..940d93c7 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,30 @@ export const useTimerQueryInvalidation = () => { queryClient.invalidateQueries({ queryKey: getGetTodoDetailQueryKey(todoId), }); - const invalidateTimerState = () => { + + /** + * 일시정지/재개/연장처럼 타이머가 계속 진행 중인 액션 이후 무효화 + */ + const invalidateTimerProgress = () => { + invalidateActiveTimer(); + invalidateHomeView(); + invalidateTimeBoxes(); + invalidateStatistics(); + }; + + /** + * 완료/중지처럼 타이머가 종료되는 액션 이후 무효화 + * + * @param todoId - 투두 상세 쿼리를 무효화할 투두 ID. 없으면 투두 상세는 건너뜁니다. + */ + const invalidateTimerFinish = (todoId?: number) => { invalidateActiveTimer(); invalidateHomeView(); invalidateTimeBoxes(); + invalidateTodayView(); invalidateStatistics(); + invalidateFocusTodo(); + if (todoId) invalidateTodoDetail(todoId); }; return { @@ -45,6 +64,7 @@ export const useTimerQueryInvalidation = () => { invalidateTodayView, invalidateFocusTodo, invalidateTodoDetail, - invalidateTimerState, + invalidateTimerProgress, + invalidateTimerFinish, }; }; 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/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, + }; +}; 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..ab128795 --- /dev/null +++ b/apps/timo-web/utils/timer/overtime-storage.ts @@ -0,0 +1,49 @@ +const OVERTIME_BASE_KEY = "timo:overtime-base"; + +const isBrowser = typeof window !== "undefined"; + +export interface OvertimeBase { + timerId: number; + 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에서 가져옵니다. + * + * @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: unknown = JSON.parse(raw); + return isOvertimeBase(parsed) ? 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 }), + ); +};