From a8065565b11188063db1fafe1a8a0bdde47ebcf4 Mon Sep 17 00:00:00 2001 From: jjangminii Date: Mon, 13 Jul 2026 21:25:36 +0900 Subject: [PATCH 01/45] =?UTF-8?q?feat(web):=20=ED=83=80=EC=9D=B4=EB=A8=B8?= =?UTF-8?q?=20=EC=A0=95=EC=A7=80=20API=EB=A5=BC=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실행 중인 타이머를 정지 모달에서 완료 처리할 때 completeTimer 대신 stopTimer가 호출되도록 완료 모달 진입 경로를 구분했습니다 - FocusSessionContainer에 useStopTimer 뮤테이션과 handleStopTimer를 추가했습니다 - TimerSessionControls에 onStop prop을 추가하고, 사이드바 TimerPanel의 목업 핸들러도 함께 갱신했습니다 --- .../_containers/FocusSessionContainer.tsx | 19 +++++++++++++++++++ .../layout/sidebar/time/TimerPanel.tsx | 2 ++ .../components/timer/TimerSessionControls.tsx | 15 ++++++++++++--- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx index 960bc9b6..cc9dbd50 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx @@ -11,6 +11,7 @@ import { useCompleteTimer, useExtendTimer, useStartTimer, + useStopTimer, getGetActiveTimerQueryKey, } from "@/api/generated/endpoints/timer/timer"; import { @@ -83,6 +84,16 @@ export const FocusSessionContainer = () => { onError: handleMutationError, }, }); + const { mutate: stopTimer } = useStopTimer({ + mutation: { + onSuccess: (response) => { + setFeedbackText(response.data?.aiFeedback ?? undefined); + invalidateActiveTimer(); + invalidateFocusTodo(); + }, + onError: handleMutationError, + }, + }); const { mutate: changeTodoStatus } = useChangeTodoStatus({ mutation: { onSuccess: invalidateFocusTodo, onError: handleMutationError }, }); @@ -123,6 +134,12 @@ export const FocusSessionContainer = () => { completeTimer({ timerId: timer.timerId }); }; + const handleStopTimer = () => { + if (!timer) return; + + stopTimer({ timerId: timer.timerId }); + }; + const handleToggleCompleted = (completed: boolean) => { if (!todo) return; @@ -181,6 +198,7 @@ export const FocusSessionContainer = () => { actualMinutes={0} onExtend={() => {}} onComplete={() => {}} + onStop={() => {}} disabled /> @@ -247,6 +265,7 @@ export const FocusSessionContainer = () => { feedbackText={feedbackText} onExtend={handleExtendTimer} onComplete={handleCompleteTimer} + onStop={handleStopTimer} /> diff --git a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx index 80f1df9b..685f36d6 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx @@ -17,6 +17,7 @@ export const TimerPanel = () => { setStatus((prev) => (prev === "RUNNING" ? "PAUSED" : "RUNNING")); const handleExtendTimer = () => {}; const handleCompleteTimer = () => setStatus("PAUSED"); + const handleStopTimer = () => setStatus("PAUSED"); return (
@@ -35,6 +36,7 @@ export const TimerPanel = () => { actualMinutes={PLANNED_MINUTES} onExtend={handleExtendTimer} onComplete={handleCompleteTimer} + onStop={handleStopTimer} />
); diff --git a/apps/timo-web/components/timer/TimerSessionControls.tsx b/apps/timo-web/components/timer/TimerSessionControls.tsx index 628a2394..9bbf487f 100644 --- a/apps/timo-web/components/timer/TimerSessionControls.tsx +++ b/apps/timo-web/components/timer/TimerSessionControls.tsx @@ -26,6 +26,7 @@ export interface TimerSessionControlsProps { feedbackText?: string; onExtend: (minutes: number) => void; onComplete: () => void; + onStop: () => void; disabled?: boolean; } @@ -41,11 +42,13 @@ export const TimerSessionControls = forwardRef< feedbackText, onExtend, onComplete, + onStop, disabled = false, }, ref, ) { const [step, setStep] = useState("end"); + const [completeOrigin, setCompleteOrigin] = useState<"end" | "stop">("end"); const stopModalTriggerRef = useRef(null); useImperativeHandle(ref, () => ({ @@ -109,13 +112,19 @@ export const TimerSessionControls = forwardRef< setIsFromEndModal(true); setStep("extend"); }} - onComplete={() => setStep("complete")} + onComplete={() => { + setCompleteOrigin("end"); + setStep("complete"); + }} /> )} {step === "stop" && ( setStep("complete")} + onSwitch={() => { + setCompleteOrigin("stop"); + setStep("complete"); + }} /> )} {step === "extend" && ( @@ -144,7 +153,7 @@ export const TimerSessionControls = forwardRef< plannedMinutes={plannedMinutes} actualMinutes={actualMinutes} feedbackText={feedbackText} - onComplete={onComplete} + onComplete={completeOrigin === "stop" ? onStop : onComplete} /> )} From a729882b49bed741e82bb03d04e02737855e38b6 Mon Sep 17 00:00:00 2001 From: jjangminii Date: Mon, 13 Jul 2026 22:34:19 +0900 Subject: [PATCH 02/45] =?UTF-8?q?feat(web):=20=ED=99=88/=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20=ED=83=80=EC=9D=B4=EB=A8=B8=20API=EB=A5=BC?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=ED=95=9C=EB=8B=A4=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도메인 간 직접 import 금지 규칙에 맞춰 타이머 쿼리 훅을 focus 전용 위치에서 hooks/·types/로 이동했습니다 - 홈 투두 카드의 재생 버튼을 목업 대신 useStartTimer/useChangeStatus에 연동했습니다 - 사이드바 Timer 탭을 실제 useActiveTimer 데이터로 연동하고, 재생·일시정지·연장·정지·완료 컨트롤을 실제 뮤테이션에 연결했습니다 - 홈 투두 카드의 계획 시간 배지가 진행 중인 타이머의 연장 시간을 반영하도록 했습니다 --- .../home/_containers/HomeTodoContainer.tsx | 7 +- .../home/_hooks/use-home-todos-by-date.ts | 35 ++++- .../_containers/FocusSessionContainer.tsx | 2 +- .../layout/sidebar/time/TimerPanel.tsx | 128 +++++++++++++++--- .../_queries => hooks}/use-active-timer.ts | 2 +- .../focus/_types => types}/timer-type.ts | 0 6 files changed, 149 insertions(+), 25 deletions(-) rename apps/timo-web/{app/[locale]/(main)/focus/_queries => hooks}/use-active-timer.ts (86%) rename apps/timo-web/{app/[locale]/(main)/focus/_types => types}/timer-type.ts (100%) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx index 6b05153e..1b0e3c7f 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx @@ -45,6 +45,7 @@ export const HomeTodoContainer = () => { const { todosByDate, + activeTimer, handleToggleCompleted, handleTogglePlay, handleToggleSubtaskCompleted, @@ -98,6 +99,10 @@ export const HomeTodoContainer = () => {
{todos.map((todo) => { const [firstSubtask] = todo.subtasks; + const durationSeconds = + activeTimer && activeTimer.todoId === todo.todoId + ? activeTimer.plannedSeconds + activeTimer.extendedSeconds + : todo.durationSeconds; return ( { todoId={todo.todoId} title={todo.title} isCompleted={todo.completed} - durationSeconds={todo.durationSeconds} + durationSeconds={durationSeconds} priority={todo.priority} tagName={ todo.tag && 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 6abd08f8..f96db2ea 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 @@ -7,22 +7,40 @@ import type { HomeViewDay } from "@/app/[locale]/(main)/(with-time-sidebar)/home import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; import { getGetHomeQueryKey } from "@/api/generated/endpoints/home/home"; +import { + useChangeStatus, + useStartTimer, + getGetActiveTimerQueryKey, +} from "@/api/generated/endpoints/timer/timer"; import { useChangeSubtaskStatus, useChangeTodoStatus, useReorderTodo, } from "@/api/generated/endpoints/todo/todo"; import { reorderTodos } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-order"; +import { useActiveTimer } from "@/hooks/use-active-timer"; import { useTimeSidebarStore } from "@/stores/time-sidebar/useTimeSidebarStore"; export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { const [todosByDate, setTodosByDate] = useState>({}); const openTimerPanel = useTimeSidebarStore((state) => state.openTimerPanel); const queryClient = useQueryClient(); + const { data: activeTimer } = useActiveTimer(); const { mutate: changeTodoStatus } = useChangeTodoStatus(); const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); const { mutate: reorderTodo } = useReorderTodo(); + const invalidateTimerState = () => { + queryClient.invalidateQueries({ queryKey: getGetActiveTimerQueryKey() }); + queryClient.invalidateQueries({ queryKey: getGetHomeQueryKey() }); + }; + const { mutate: startTimer } = useStartTimer({ + mutation: { onSuccess: invalidateTimerState }, + }); + const { mutate: changeStatus } = useChangeStatus({ + mutation: { onSuccess: invalidateTimerState }, + }); + useEffect(() => { setTodosByDate( Object.fromEntries(apiDays.map((day) => [day.date, day.todos])), @@ -66,19 +84,23 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { }; const handleTogglePlay = (dateKey: string, todoId: number) => { - // TODO: API const willRun = todosByDate[dateKey]?.find((todo) => todo.todoId === todoId) ?.timerStatus !== "RUNNING"; - updateTodo(dateKey, todoId, (todo) => ({ - ...todo, - timerStatus: todo.timerStatus === "RUNNING" ? "STOPPED" : "RUNNING", - })); - if (willRun) { openTimerPanel(); } + + if (activeTimer && activeTimer.todoId === todoId) { + changeStatus({ + timerId: activeTimer.timerId, + data: { action: activeTimer.status === "RUNNING" ? "PAUSE" : "RESUME" }, + }); + return; + } + + startTimer({ todoId }); }; const handleToggleSubtaskCompleted = ( @@ -133,6 +155,7 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { return { todosByDate, + activeTimer, handleToggleCompleted, handleTogglePlay, handleToggleSubtaskCompleted, diff --git a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx index cc9dbd50..8fbe2f65 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx @@ -21,7 +21,6 @@ import { import { FocusEmptyTaskItem } from "@/app/[locale]/(main)/focus/_components/FocusEmptyTaskItem"; import { FocusTaskItem } from "@/app/[locale]/(main)/focus/_components/FocusTaskItem"; import { FocusHeaderContainer } from "@/app/[locale]/(main)/focus/_containers/FocusHeaderContainer"; -import { useActiveTimer } from "@/app/[locale]/(main)/focus/_queries/use-active-timer"; import { useFocusTodo } from "@/app/[locale]/(main)/focus/_queries/use-focus-todo"; import { convertDateToBadgeText, @@ -34,6 +33,7 @@ import { type TimerSessionControlsHandle, } from "@/components/timer/TimerSessionControls"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; +import { useActiveTimer } from "@/hooks/use-active-timer"; import { convertDurationToMinutes } from "@/utils/convert-duration-to-minutes"; import { convertDurationToTimeText } from "@/utils/convert-duration-to-time-text"; import { formatDurationLabel } from "@/utils/format-duration-label"; diff --git a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx index 685f36d6..8cd4c7af 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx @@ -1,42 +1,138 @@ "use client"; import { TimerOnIcon } from "@repo/timo-design-system/icons"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; import { useState } from "react"; +import { getGetHomeQueryKey } from "@/api/generated/endpoints/home/home"; +import { + useChangeStatus, + useCompleteTimer, + useExtendTimer, + useStopTimer, + getGetActiveTimerQueryKey, +} from "@/api/generated/endpoints/timer/timer"; import { Timer } from "@/components/timer/Timer"; import { TimerSessionControls } from "@/components/timer/TimerSessionControls"; +import { useActiveTimer } from "@/hooks/use-active-timer"; +import { convertDurationToMinutes } from "@/utils/convert-duration-to-minutes"; +import { convertDurationToTimeText } from "@/utils/convert-duration-to-time-text"; +import { formatDurationLabel } from "@/utils/format-duration-label"; -type TimerStatus = "RUNNING" | "PAUSED"; +export const TimerPanel = () => { + const queryClient = useQueryClient(); + const tDuration = useTranslations("Focus.duration"); + const [feedbackText, setFeedbackText] = useState(); -const PLANNED_MINUTES = 12; + const { data: activeTimer } = useActiveTimer(); -export const TimerPanel = () => { - const [status, setStatus] = useState("PAUSED"); + const invalidateActiveTimer = () => + queryClient.invalidateQueries({ queryKey: getGetActiveTimerQueryKey() }); + const invalidateHomeView = () => + queryClient.invalidateQueries({ queryKey: getGetHomeQueryKey() }); + + const { mutate: changeStatus } = useChangeStatus({ + mutation: { + onSuccess: () => { + invalidateActiveTimer(); + invalidateHomeView(); + }, + }, + }); + const { mutate: extendTimer } = useExtendTimer({ + mutation: { onSuccess: invalidateActiveTimer }, + }); + const { mutate: completeTimer } = useCompleteTimer({ + mutation: { + onSuccess: (response) => { + setFeedbackText(response.data?.aiFeedback ?? undefined); + invalidateActiveTimer(); + invalidateHomeView(); + }, + }, + }); + const { mutate: stopTimer } = useStopTimer({ + mutation: { + onSuccess: (response) => { + setFeedbackText(response.data?.aiFeedback ?? undefined); + invalidateActiveTimer(); + invalidateHomeView(); + }, + }, + }); + + const isRunning = activeTimer?.status === "RUNNING"; + const isTimeUp = activeTimer ? activeTimer.remainingSeconds <= 0 : false; + + const handleTogglePlay = () => { + if (!activeTimer) return; + + changeStatus({ + timerId: activeTimer.timerId, + data: { action: isRunning ? "PAUSE" : "RESUME" }, + }); + }; + + const handleExtendTimer = (minutes: number) => { + if (!activeTimer) return; - const handleTogglePlay = () => - setStatus((prev) => (prev === "RUNNING" ? "PAUSED" : "RUNNING")); - const handleExtendTimer = () => {}; - const handleCompleteTimer = () => setStatus("PAUSED"); - const handleStopTimer = () => setStatus("PAUSED"); + extendTimer({ + timerId: activeTimer.timerId, + data: { extendMinutes: minutes }, + }); + }; + + const handleCompleteTimer = () => { + if (!activeTimer) return; + + completeTimer({ timerId: activeTimer.timerId }); + }; + + const handleStopTimer = () => { + if (!activeTimer) return; + + stopTimer({ timerId: activeTimer.timerId }); + }; + + const plannedSeconds = activeTimer + ? activeTimer.plannedSeconds + activeTimer.extendedSeconds + : 0; + const remainingSeconds = activeTimer ? activeTimer.remainingSeconds : 0; + const progress = + plannedSeconds > 0 + ? ((plannedSeconds - remainingSeconds) / plannedSeconds) * 100 + : 0; + const plannedMinutes = convertDurationToMinutes(plannedSeconds); + const actualMinutes = convertDurationToMinutes( + activeTimer?.elapsedSeconds ?? 0, + ); return (
} - time="10:30" - plannedLabel="12M" - progress={70} + icon={activeTimer ? : undefined} + time={convertDurationToTimeText(remainingSeconds)} + plannedLabel={formatDurationLabel( + plannedMinutes, + tDuration("hourUnit"), + tDuration("minuteUnit"), + )} + progress={progress} size="sm" />
); diff --git a/apps/timo-web/app/[locale]/(main)/focus/_queries/use-active-timer.ts b/apps/timo-web/hooks/use-active-timer.ts similarity index 86% rename from apps/timo-web/app/[locale]/(main)/focus/_queries/use-active-timer.ts rename to apps/timo-web/hooks/use-active-timer.ts index 8ed8920a..553a372c 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_queries/use-active-timer.ts +++ b/apps/timo-web/hooks/use-active-timer.ts @@ -6,7 +6,7 @@ import { getActiveTimer, getGetActiveTimerQueryKey, } from "@/api/generated/endpoints/timer/timer"; -import { activeTimerSchema } from "@/app/[locale]/(main)/focus/_types/timer-type"; +import { activeTimerSchema } from "@/types/timer-type"; export const useActiveTimer = () => useQuery({ diff --git a/apps/timo-web/app/[locale]/(main)/focus/_types/timer-type.ts b/apps/timo-web/types/timer-type.ts similarity index 100% rename from apps/timo-web/app/[locale]/(main)/focus/_types/timer-type.ts rename to apps/timo-web/types/timer-type.ts From 5fb6a9e5e7c9740075fb93c89e28affc81802f77 Mon Sep 17 00:00:00 2001 From: jjangminii Date: Mon, 13 Jul 2026 22:35:31 +0900 Subject: [PATCH 03/45] =?UTF-8?q?fix(web):=20=ED=83=80=EC=9D=B4=EB=A8=B8?= =?UTF-8?q?=20=EC=99=84=EB=A3=8C/=EC=A4=91=EC=A7=80=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=EC=9D=84=20=EC=8B=9C=EA=B0=84=20=EC=86=8C?= =?UTF-8?q?=EC=A7=84=20=EC=97=AC=EB=B6=80=EB=A1=9C=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 완료 모달 진입 경로(정지 모달 vs 종료 모달)로 완료/중지 API를 나누던 로직을 계획 시간 소진 여부(remainingSeconds)로 수정했습니다 - 체크박스로 투두를 완료 처리할 때 일시정지된 타이머가 있으면 백엔드가 거부하는 문제를, 실행 중이거나 일시정지된 타이머가 있으면 정지 확인 모달을 거치도록 수정해 해결했습니다 --- .../focus/_containers/FocusSessionContainer.tsx | 5 ++++- .../components/timer/TimerSessionControls.tsx | 16 ++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx index 8fbe2f65..de11ecdf 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx @@ -143,7 +143,7 @@ export const FocusSessionContainer = () => { const handleToggleCompleted = (completed: boolean) => { if (!todo) return; - if (completed && isRunning) { + if (completed && timer) { timerSessionControlsRef.current?.openStopModal(); return; } @@ -196,6 +196,7 @@ export const FocusSessionContainer = () => { onTogglePlay={() => {}} plannedMinutes={0} actualMinutes={0} + isTimeUp={false} onExtend={() => {}} onComplete={() => {}} onStop={() => {}} @@ -211,6 +212,7 @@ export const FocusSessionContainer = () => { ? timer.plannedSeconds + timer.extendedSeconds : (todo.durationSeconds ?? 0); const remainingSeconds = timer ? timer.remainingSeconds : plannedSeconds; + const isTimeUp = timer ? timer.remainingSeconds <= 0 : false; const progress = plannedSeconds > 0 ? ((plannedSeconds - remainingSeconds) / plannedSeconds) * 100 @@ -263,6 +265,7 @@ export const FocusSessionContainer = () => { plannedMinutes={plannedMinutes} actualMinutes={actualMinutes} feedbackText={feedbackText} + isTimeUp={isTimeUp} onExtend={handleExtendTimer} onComplete={handleCompleteTimer} onStop={handleStopTimer} diff --git a/apps/timo-web/components/timer/TimerSessionControls.tsx b/apps/timo-web/components/timer/TimerSessionControls.tsx index 9bbf487f..b3c1cb29 100644 --- a/apps/timo-web/components/timer/TimerSessionControls.tsx +++ b/apps/timo-web/components/timer/TimerSessionControls.tsx @@ -24,6 +24,8 @@ export interface TimerSessionControlsProps { plannedMinutes: number; actualMinutes: number; feedbackText?: string; + /** 계획된 시간이 모두 경과했는지 — true면 완료(complete)로, false면 조기 종료(stop)로 처리한다 */ + isTimeUp: boolean; onExtend: (minutes: number) => void; onComplete: () => void; onStop: () => void; @@ -40,6 +42,7 @@ export const TimerSessionControls = forwardRef< plannedMinutes, actualMinutes, feedbackText, + isTimeUp, onExtend, onComplete, onStop, @@ -48,7 +51,6 @@ export const TimerSessionControls = forwardRef< ref, ) { const [step, setStep] = useState("end"); - const [completeOrigin, setCompleteOrigin] = useState<"end" | "stop">("end"); const stopModalTriggerRef = useRef(null); useImperativeHandle(ref, () => ({ @@ -112,19 +114,13 @@ export const TimerSessionControls = forwardRef< setIsFromEndModal(true); setStep("extend"); }} - onComplete={() => { - setCompleteOrigin("end"); - setStep("complete"); - }} + onComplete={() => setStep("complete")} /> )} {step === "stop" && ( { - setCompleteOrigin("stop"); - setStep("complete"); - }} + onSwitch={() => setStep("complete")} /> )} {step === "extend" && ( @@ -153,7 +149,7 @@ export const TimerSessionControls = forwardRef< plannedMinutes={plannedMinutes} actualMinutes={actualMinutes} feedbackText={feedbackText} - onComplete={completeOrigin === "stop" ? onStop : onComplete} + onComplete={isTimeUp ? onComplete : onStop} /> )} From 6989e1a12bd6bb0acbc47c6c4931300a9a086541 Mon Sep 17 00:00:00 2001 From: jjangminii Date: Mon, 13 Jul 2026 22:52:06 +0900 Subject: [PATCH 04/45] =?UTF-8?q?refactor(web):=20=ED=99=9C=EC=84=B1=20?= =?UTF-8?q?=ED=83=80=EC=9D=B4=EB=A8=B8=20=ED=8F=B4=EB=A7=81=EC=9D=84=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=B9=B4?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8=EB=8B=A4=EC=9A=B4=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=8C=80=EC=B2=B4=ED=95=9C=EB=8B=A4=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RUNNING 상태에서 1초마다 /api/v1/timers/active를 재요청하던 refetchInterval을 제거했습니다 - 대신 dataUpdatedAt 기준으로 클라이언트에서 매초 remainingSeconds/elapsedSeconds를 보정 계산하도록 했습니다 - refetchOnMount를 always로 설정해 화면 전환 시에는 최신 상태를 한 번 가져오도록 했습니다 --- apps/timo-web/hooks/use-active-timer.ts | 37 ++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/timo-web/hooks/use-active-timer.ts b/apps/timo-web/hooks/use-active-timer.ts index 553a372c..123a8f42 100644 --- a/apps/timo-web/hooks/use-active-timer.ts +++ b/apps/timo-web/hooks/use-active-timer.ts @@ -1,6 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; import { getActiveTimer, @@ -8,11 +9,39 @@ import { } from "@/api/generated/endpoints/timer/timer"; import { activeTimerSchema } from "@/types/timer-type"; -export const useActiveTimer = () => - useQuery({ +export const useActiveTimer = () => { + const query = useQuery({ queryKey: getGetActiveTimerQueryKey(), queryFn: ({ signal }) => getActiveTimer(undefined, signal), select: ({ data }) => (data ? activeTimerSchema.parse(data) : undefined), - refetchInterval: (query) => - query.state.data?.data?.status === "RUNNING" ? 1000 : false, + refetchOnMount: "always", }); + + const { data, dataUpdatedAt } = query; + const isRunning = data?.status === "RUNNING"; + + const [tick, setTick] = useState(0); + + useEffect(() => { + if (!isRunning) return; + + const id = setInterval(() => setTick((prev) => prev + 1), 1000); + return () => clearInterval(id); + }, [isRunning, dataUpdatedAt]); + + const tickedData = useMemo(() => { + if (!data || !isRunning) return data; + + const elapsedSinceFetch = Math.floor((Date.now() - dataUpdatedAt) / 1000); + + return { + ...data, + elapsedSeconds: data.elapsedSeconds + elapsedSinceFetch, + remainingSeconds: Math.max(0, data.remainingSeconds - elapsedSinceFetch), + }; + // tick은 값 자체가 아니라 매 초 재계산을 트리거하기 위한 용도로만 의존성에 둔다 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, isRunning, dataUpdatedAt, tick]); + + return { ...query, data: tickedData }; +}; From 687269e6dce64964f067bed9a16c91966e411bfc Mon Sep 17 00:00:00 2001 From: jjangminii Date: Tue, 14 Jul 2026 01:52:07 +0900 Subject: [PATCH 05/45] =?UTF-8?q?fix(web):=20=ED=8F=AC=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=20=ED=83=9C=EC=8A=A4=ED=81=AC=20=EC=95=84=EC=9D=B4=ED=85=9C?= =?UTF-8?q?=EC=9D=98=20=EC=8B=9C=EA=B0=84=20=EB=B0=B0=EC=A7=80=EA=B0=80=20?= =?UTF-8?q?=EC=97=B0=EC=9E=A5=EB=B6=84=EC=9D=84=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - durationText가 todo.durationSeconds를 그대로 써서 활성 타이머의 연장 시간이 배지에 반영되지 않던 문제를, 이미 계산돼 있던 plannedSeconds(연장분 포함)를 쓰도록 수정했습니다 --- .../[locale]/(main)/focus/_containers/FocusSessionContainer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx index de11ecdf..cb0cd0db 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx @@ -235,7 +235,7 @@ export const FocusSessionContainer = () => { title={todo.title} completed={todo.completed} dateText={dateText} - durationText={convertDurationToTimeText(todo.durationSeconds ?? 0)} + durationText={convertDurationToTimeText(plannedSeconds)} isRunning={isRunning} subtasks={todo.subtasks} onToggleCompleted={handleToggleCompleted} From b6cbf99a4eb8aa1c7595091c05297c75bbae7dbd Mon Sep 17 00:00:00 2001 From: jjangminii Date: Tue, 14 Jul 2026 01:53:45 +0900 Subject: [PATCH 06/45] =?UTF-8?q?feat(web):=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=86=8C=EC=A7=84=20=EC=8B=9C=20=EC=A2=85=EB=A3=8C=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=9E=90=EB=8F=99=20=ED=91=9C=EC=8B=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EC=97=B0=EC=9E=A5=20=EA=B5=AC=EA=B0=84=20=EC=98=A4=EB=B2=84?= =?UTF-8?q?=ED=83=80=EC=9E=84=20=EB=A7=81=EC=9D=84=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 남은 시간이 0이 되는 순간 기존 "타이머 종료!" 모달을 자동으로 띄우도록 TimerSessionControlsHandle에 openEndModal을 추가했습니다 - 완료 모달에서 완료하기를 누르면 isTimeUp 기준으로 completeTimer/stopTimer가 자동 분기됩니다 - 시간이 다 된 뒤(모달이 뜬 뒤) 연장한 구간만 노란색 오버타임 링으로 표시하고, 시간이 다 되기 전 미리 연장한 경우는 일반 파란 링으로 유지되도록 구분했습니다 - 포커스 페이지와 사이드바 Timer 탭 둘 다 동일하게 적용했습니다 --- .../_containers/FocusSessionContainer.tsx | 40 ++++++++++++++- .../layout/sidebar/time/TimerPanel.tsx | 50 ++++++++++++++++++- .../components/timer/TimerSessionControls.tsx | 11 ++-- 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx index cb0cd0db..31594c63 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx @@ -3,7 +3,7 @@ import { TimerOnIcon } from "@repo/timo-design-system/icons"; import { useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; import { @@ -41,11 +41,15 @@ import { formatDurationLabel } from "@/utils/format-duration-label"; export const FocusSessionContainer = () => { const queryClient = useQueryClient(); const timerSessionControlsRef = useRef(null); + const wasTimeUpRef = useRef(false); const tWeekday = useTranslations("Common.weekday"); const tDuration = useTranslations("Focus.duration"); const tToast = useTranslations("Toast"); const [feedbackText, setFeedbackText] = useState(); const [isErrorToastOpen, setIsErrorToastOpen] = useState(false); + const [overtimeBaseSeconds, setOvertimeBaseSeconds] = useState( + null, + ); const { data: focusView } = useFocusTodo(); const { data: activeTimer } = useActiveTimer(); @@ -107,6 +111,18 @@ export const FocusSessionContainer = () => { ? activeTimer : undefined; const isRunning = timer?.status === "RUNNING"; + const isTimeUp = timer ? timer.remainingSeconds <= 0 : false; + + useEffect(() => { + if (isTimeUp && !wasTimeUpRef.current) { + timerSessionControlsRef.current?.openEndModal(); + } + wasTimeUpRef.current = isTimeUp; + }, [isTimeUp]); + + useEffect(() => { + setOvertimeBaseSeconds(null); + }, [timer?.timerId]); const handleTogglePlay = () => { if (!todo) return; @@ -125,6 +141,10 @@ export const FocusSessionContainer = () => { const handleExtendTimer = (minutes: number) => { if (!timer) return; + if (isTimeUp) { + setOvertimeBaseSeconds(timer.plannedSeconds + timer.extendedSeconds); + } + extendTimer({ timerId: timer.timerId, data: { extendMinutes: minutes } }); }; @@ -212,11 +232,25 @@ export const FocusSessionContainer = () => { ? timer.plannedSeconds + timer.extendedSeconds : (todo.durationSeconds ?? 0); const remainingSeconds = timer ? timer.remainingSeconds : plannedSeconds; - const isTimeUp = timer ? timer.remainingSeconds <= 0 : false; 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 actualMinutes = convertDurationToMinutes(timer?.elapsedSeconds ?? 0); @@ -255,6 +289,8 @@ export const FocusSessionContainer = () => { tDuration("minuteUnit"), )} progress={progress} + isOvertime={isOvertime} + overtimeProgress={overtimeProgress} size="lg" /> diff --git a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx index 8cd4c7af..81685e6d 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx @@ -3,7 +3,7 @@ import { TimerOnIcon } from "@repo/timo-design-system/icons"; import { useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { getGetHomeQueryKey } from "@/api/generated/endpoints/home/home"; import { @@ -14,7 +14,10 @@ import { getGetActiveTimerQueryKey, } from "@/api/generated/endpoints/timer/timer"; import { Timer } from "@/components/timer/Timer"; -import { TimerSessionControls } from "@/components/timer/TimerSessionControls"; +import { + TimerSessionControls, + type TimerSessionControlsHandle, +} from "@/components/timer/TimerSessionControls"; import { useActiveTimer } from "@/hooks/use-active-timer"; import { convertDurationToMinutes } from "@/utils/convert-duration-to-minutes"; import { convertDurationToTimeText } from "@/utils/convert-duration-to-time-text"; @@ -24,6 +27,11 @@ export const TimerPanel = () => { const queryClient = useQueryClient(); const tDuration = useTranslations("Focus.duration"); const [feedbackText, setFeedbackText] = useState(); + const [overtimeBaseSeconds, setOvertimeBaseSeconds] = useState( + null, + ); + const timerSessionControlsRef = useRef(null); + const wasTimeUpRef = useRef(false); const { data: activeTimer } = useActiveTimer(); @@ -65,6 +73,17 @@ export const TimerPanel = () => { const isRunning = activeTimer?.status === "RUNNING"; const isTimeUp = activeTimer ? activeTimer.remainingSeconds <= 0 : false; + useEffect(() => { + if (isTimeUp && !wasTimeUpRef.current) { + timerSessionControlsRef.current?.openEndModal(); + } + wasTimeUpRef.current = isTimeUp; + }, [isTimeUp]); + + useEffect(() => { + setOvertimeBaseSeconds(null); + }, [activeTimer?.timerId]); + const handleTogglePlay = () => { if (!activeTimer) return; @@ -77,6 +96,12 @@ export const TimerPanel = () => { const handleExtendTimer = (minutes: number) => { if (!activeTimer) return; + if (isTimeUp) { + setOvertimeBaseSeconds( + activeTimer.plannedSeconds + activeTimer.extendedSeconds, + ); + } + extendTimer({ timerId: activeTimer.timerId, data: { extendMinutes: minutes }, @@ -103,6 +128,24 @@ export const TimerPanel = () => { 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 actualMinutes = convertDurationToMinutes( activeTimer?.elapsedSeconds ?? 0, @@ -119,10 +162,13 @@ export const TimerPanel = () => { tDuration("minuteUnit"), )} progress={progress} + isOvertime={isOvertime} + overtimeProgress={overtimeProgress} size="sm" /> void; + openEndModal: () => void; } export interface TimerSessionControlsProps { @@ -51,12 +52,16 @@ export const TimerSessionControls = forwardRef< ref, ) { const [step, setStep] = useState("end"); - const stopModalTriggerRef = useRef(null); + const hiddenModalTriggerRef = useRef(null); useImperativeHandle(ref, () => ({ openStopModal: () => { setStep("stop"); - stopModalTriggerRef.current?.click(); + hiddenModalTriggerRef.current?.click(); + }, + openEndModal: () => { + setStep("end"); + hiddenModalTriggerRef.current?.click(); }, })); const [selectedPreset, setSelectedPreset] = useState( @@ -100,7 +105,7 @@ export const TimerSessionControls = forwardRef< disabled={disabled} />