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 44f2b106..28ec4ab8 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 @@ -2,12 +2,13 @@ import { cn } from "@repo/timo-design-system/utils"; import { useTranslations } from "next-intl"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import type { HomeViewFilter } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type"; import { HomeTodoCard } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_components/todo-card/HomeTodoCard"; import { HomeDayHeaderContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeDayHeaderContainer"; +import { HomeStopCompleteModalContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeStopCompleteModalContainer"; import { scrollContainerToToday, useHomeTodayScrollRef, @@ -15,13 +16,22 @@ import { import { useHomeTodosByDate } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date"; import { useHomeViewMode } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-view-mode"; import { useHomeView } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view"; +import { AnimatedToast } from "@/components/toast/AnimatedToast"; import { DetailTodoModalContainer } from "@/components/todo-modal/detail/DetailTodoModalContainer"; import { DndSortableListProvider } from "@/providers/dnd/DndSortableListProvider"; import { formatDateKey } from "@/utils/date/date"; +import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; import { getDefaultTagLabelKey } from "@/utils/todo/tag-label"; +interface PendingCompleteTodo { + token: number; + dateKey: string; + todoId: number; +} + export const HomeTodoContainer = () => { const tCommon = useTranslations("Common"); + const tToast = useTranslations("Toast"); const { isWeekView, referenceDate } = useHomeViewMode(); const scrollRef = useHomeTodayScrollRef(); @@ -32,19 +42,51 @@ export const HomeTodoContainer = () => { const { data: homeViewData } = useHomeView({ filter, baseDate }); const days = homeViewData.days; + const [pendingCompleteTodo, setPendingCompleteTodo] = + useState(null); + const [feedbackText, setFeedbackText] = useState(); + const [isTimerRunningToastOpen, setIsTimerRunningToastOpen] = useState(false); + const { todosByDate, + activeTimer, handleToggleCompleted, handleTogglePlay, handleToggleSubtaskCompleted, handleDeleteTodo, handleReorderTodo, - } = useHomeTodosByDate(days); + confirmStopAndComplete, + } = useHomeTodosByDate(days, { + onNeedStopConfirm: (dateKey, todoId) => + setPendingCompleteTodo({ token: Date.now(), dateKey, todoId }), + onTimerAlreadyRunning: () => setIsTimerRunningToastOpen(true), + onStopFeedback: setFeedbackText, + }); + + const handleConfirmPendingComplete = () => { + if (!pendingCompleteTodo) return; + + confirmStopAndComplete( + pendingCompleteTodo.dateKey, + pendingCompleteTodo.todoId, + ); + + setPendingCompleteTodo(null); + }; useEffect(() => { scrollContainerToToday(scrollRef.current); }, [isWeekView, scrollRef]); + const plannedMinutes = activeTimer + ? convertDurationToMinutes( + activeTimer.plannedSeconds + activeTimer.extendedSeconds, + ) + : 0; + const actualMinutes = activeTimer + ? convertDurationToMinutes(activeTimer.elapsedSeconds) + : 0; + return (
{
{todos.map((todo) => { const [firstSubtask] = todo.subtasks; + const isActiveTodo = + activeTimer && activeTimer.todoId === todo.todoId; + const durationSeconds = isActiveTodo + ? activeTimer.plannedSeconds + activeTimer.extendedSeconds + : todo.durationSeconds; + const timerStatus = isActiveTodo + ? activeTimer.status + : todo.timerStatus; const todoTagLabelKey = todo.tag ? getDefaultTagLabelKey(todo.tag.tagId) @@ -112,12 +162,12 @@ export const HomeTodoContainer = () => { todoId={todo.todoId} title={todo.title} isCompleted={todo.completed} - durationSeconds={todo.durationSeconds} + durationSeconds={durationSeconds} priority={todo.priority} tagName={todoTagName} hasMemo={todo.hasMemo} isRepeated={todo.isRepeated} - timerStatus={todo.timerStatus} + timerStatus={timerStatus} subtaskTitle={firstSubtask?.content} isSubtaskCompleted={firstSubtask?.completed} onClickTodo={openDetailTodoModal} @@ -152,6 +202,20 @@ export const HomeTodoContainer = () => {
); })} + + + + setIsTimerRunningToastOpen(false)} + message={tToast("timerAlreadyRunning")} + />
); }; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeStopCompleteModalContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeStopCompleteModalContainer.tsx new file mode 100644 index 00000000..0869dcf2 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeStopCompleteModalContainer.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Modal } from "@repo/timo-design-system/ui"; +import { useEffect, useRef, useState } from "react"; + +import { TimerCompleteModalPanel } from "@/components/timer/TimerCompleteModalPanel"; +import { TimerStopModalPanel } from "@/components/timer/TimerStopModalPanel"; + +export interface HomeStopCompleteModalContainerProps { + pendingToken: number | null; + plannedMinutes: number; + actualMinutes: number; + feedbackText?: string; + onConfirm: () => void; +} + +export const HomeStopCompleteModalContainer = ({ + pendingToken, + plannedMinutes, + actualMinutes, + feedbackText, + onConfirm, +}: HomeStopCompleteModalContainerProps) => { + const [step, setStep] = useState<"stop" | "complete">("stop"); + const hiddenTriggerRef = useRef(null); + + useEffect(() => { + if (pendingToken === null) return; + + setStep("stop"); + hiddenTriggerRef.current?.click(); + }, [pendingToken]); + + return ( + + + ); +}; 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 cb3ef2c6..f5a17426 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 @@ -6,22 +6,53 @@ import { useEffect, useState } from "react"; import type { HomeViewDay } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type"; import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; -import { getGetHomeQueryKey } from "@/api/generated/endpoints/home/home"; +import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; +import { + useChangeStatus, + useStartTimer, + useStopTimer, +} 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 { useTimerQueryInvalidation } from "@/hooks/use-timer-query-invalidation"; import { useTimeSidebarStore } from "@/stores/time-sidebar/useTimeSidebarStore"; -export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { +export interface UseHomeTodosByDateOptions { + onNeedStopConfirm: (dateKey: string, todoId: number) => void; + onTimerAlreadyRunning: () => void; + onStopFeedback: (feedbackText: string | undefined) => void; +} + +export const useHomeTodosByDate = ( + apiDays: HomeViewDay[], + { + onNeedStopConfirm, + onTimerAlreadyRunning, + onStopFeedback, + }: UseHomeTodosByDateOptions, +) => { 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 { mutate: stopTimer } = useStopTimer(); + const { invalidateHomeView, invalidateTimerState, invalidateTimeBoxes } = + useTimerQueryInvalidation(); + + const { mutate: startTimer } = useStartTimer({ + mutation: { onSuccess: invalidateTimerState }, + }); + const { mutate: changeStatus } = useChangeStatus({ + mutation: { onSuccess: invalidateTimerState }, + }); useEffect(() => { setTodosByDate( @@ -42,8 +73,12 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { })); }; - const invalidateHomeView = () => { - queryClient.invalidateQueries({ queryKey: getGetHomeQueryKey() }); + const invalidateFocusTodo = () => { + queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); + }; + const invalidateHomeAndFocus = () => { + invalidateHomeView(); + invalidateFocusTodo(); }; const handleToggleCompleted = ( @@ -51,13 +86,21 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { todoId: number, completed: boolean, ) => { + if (completed && activeTimer?.todoId === todoId) { + onNeedStopConfirm(dateKey, todoId); + return; + } + const previous = todosByDate[dateKey] ?? []; updateTodo(dateKey, todoId, (todo) => ({ ...todo, completed })); changeTodoStatus( { todoId, data: { isCompleted: completed, date: dateKey } }, { - onSuccess: invalidateHomeView, + onSuccess: () => { + invalidateHomeAndFocus(); + invalidateTimeBoxes(); + }, onError: () => { setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); }, @@ -65,20 +108,53 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { ); }; + const confirmStopAndComplete = (dateKey: string, todoId: number) => { + if (!activeTimer) return; + + stopTimer( + { timerId: activeTimer.timerId }, + { + onSuccess: (response) => { + onStopFeedback(response.data?.aiFeedback ?? undefined); + invalidateTimerState(); + + updateTodo(dateKey, todoId, (todo) => ({ + ...todo, + completed: true, + })); + changeTodoStatus( + { todoId, data: { isCompleted: true, date: dateKey } }, + { onSuccess: invalidateHomeAndFocus }, + ); + }, + }, + ); + }; + 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; + } + + // 다른 투두의 타이머가 이미 실행/일시정지 중이면 새 타이머를 시작할 수 없다(409) + if (activeTimer) { + onTimerAlreadyRunning(); + return; + } + + startTimer({ todoId }); }; const handleToggleSubtaskCompleted = ( @@ -98,7 +174,10 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { changeSubtaskStatus( { todoId, subtaskId, data: { isCompleted: completed } }, { - onSuccess: invalidateHomeView, + onSuccess: () => { + invalidateHomeAndFocus(); + invalidateTimeBoxes(); + }, onError: () => { setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); }, @@ -131,7 +210,7 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { reorderTodo( { todoId: movedTodo.todoId, data: { newIndex: toIndex, date: dateKey } }, { - onSuccess: invalidateHomeView, + onSuccess: invalidateHomeAndFocus, onError: () => { setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); }, @@ -141,10 +220,12 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { return { todosByDate, + activeTimer, handleToggleCompleted, handleTogglePlay, handleToggleSubtaskCompleted, handleDeleteTodo, handleReorderTodo, + confirmStopAndComplete, }; }; 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 c95e9254..39feabbb 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx @@ -1,159 +1,35 @@ "use client"; -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 { useState } from "react"; -import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; -import { - useChangeStatus, - useCompleteTimer, - useExtendTimer, - useStartTimer, - getGetActiveTimerQueryKey, -} from "@/api/generated/endpoints/timer/timer"; -import { - useChangeSubtaskStatus, - useChangeTodoStatus, -} from "@/api/generated/endpoints/todo/todo"; 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 { useFocusSession } from "@/app/[locale]/(main)/focus/_hooks/use-focus-session"; import { - convertDateToBadgeText, convertDateToDayNumberText, convertDateToDayOfWeekKey, } from "@/app/[locale]/(main)/focus/_utils/date"; import { Timer } from "@/components/timer/Timer"; -import { - TimerSessionControls, - type TimerSessionControlsHandle, -} from "@/components/timer/TimerSessionControls"; +import { TimerSessionControls } from "@/components/timer/TimerSessionControls"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; -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"; export const FocusSessionContainer = () => { - const queryClient = useQueryClient(); - const timerSessionControlsRef = useRef(null); const tWeekday = useTranslations("Common.weekday"); - const tDuration = useTranslations("Focus.duration"); const tToast = useTranslations("Toast"); + const [feedbackText, setFeedbackText] = useState(); const [isErrorToastOpen, setIsErrorToastOpen] = useState(false); - const { data: focusView } = useFocusTodo(); - const { data: activeTimer } = useActiveTimer(); - - const invalidateActiveTimer = () => - queryClient.invalidateQueries({ queryKey: getGetActiveTimerQueryKey() }); - const invalidateFocusTodo = () => - queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); - const handleMutationError = () => setIsErrorToastOpen(true); - - const { mutate: startTimer } = useStartTimer({ - mutation: { - onSuccess: invalidateActiveTimer, - onError: handleMutationError, - }, - }); - const { mutate: changeStatus } = useChangeStatus({ - mutation: { - onSuccess: invalidateActiveTimer, - onError: handleMutationError, - }, + const { focusSessionState, focusSessionActions } = useFocusSession({ + onMutationError: () => setIsErrorToastOpen(true), + onFeedback: setFeedbackText, }); - const { mutate: extendTimer } = useExtendTimer({ - mutation: { - onSuccess: invalidateActiveTimer, - onError: handleMutationError, - }, - }); - const { mutate: completeTimer } = useCompleteTimer({ - mutation: { - onSuccess: (response) => { - setFeedbackText(response.data?.aiFeedback ?? undefined); - invalidateActiveTimer(); - invalidateFocusTodo(); - }, - onError: handleMutationError, - }, - }); - const { mutate: changeTodoStatus } = useChangeTodoStatus({ - mutation: { onSuccess: invalidateFocusTodo, onError: handleMutationError }, - }); - const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus({ - mutation: { onSuccess: invalidateFocusTodo, onError: handleMutationError }, - }); - - const todo = focusView.todo; - const timer = - activeTimer && todo && activeTimer.todoId === todo.todoId - ? activeTimer - : undefined; - const isRunning = timer?.status === "RUNNING"; - - const handleTogglePlay = () => { - if (!todo) return; - if (!timer) { - startTimer({ todoId: todo.todoId }); - return; - } - - changeStatus({ - timerId: timer.timerId, - data: { action: isRunning ? "PAUSE" : "RESUME" }, - }); - }; - - const handleExtendTimer = (minutes: number) => { - if (!timer) return; - - extendTimer({ timerId: timer.timerId, data: { extendMinutes: minutes } }); - }; - - const handleCompleteTimer = () => { - if (!timer) return; - - completeTimer({ timerId: timer.timerId }); - }; - - const handleToggleCompleted = (completed: boolean) => { - if (!todo) return; - - if (completed && isRunning) { - timerSessionControlsRef.current?.openStopModal(); - return; - } - - changeTodoStatus({ - todoId: todo.todoId, - data: { isCompleted: completed, date: focusView.date }, - }); - }; - - const handleToggleSubtaskCompleted = ( - subtaskId: number, - completed: boolean, - ) => { - if (!todo) return; - - changeSubtaskStatus({ - todoId: todo.todoId, - subtaskId, - data: { isCompleted: completed }, - }); - }; - - const today = new Date(focusView.date); - const dateText = convertDateToBadgeText(today); - - if (!focusView.hasTodo || !todo) { + if (!focusSessionState.focusView.hasTodo || !focusSessionState.todo) { return (
{
@@ -179,8 +57,10 @@ export const FocusSessionContainer = () => { onTogglePlay={() => {}} plannedMinutes={0} actualMinutes={0} + isTimeUp={false} onExtend={() => {}} onComplete={() => {}} + onStop={() => {}} disabled />
@@ -189,16 +69,7 @@ export const FocusSessionContainer = () => { ); } - 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 plannedMinutes = convertDurationToMinutes(plannedSeconds); - const actualMinutes = convertDurationToMinutes(timer?.elapsedSeconds ?? 0); + const todo = focusSessionState.todo; return (
@@ -210,43 +81,53 @@ export const FocusSessionContainer = () => {
} - time={convertDurationToTimeText(remainingSeconds)} + time={convertDurationToTimeText(focusSessionState.remainingSeconds)} plannedLabel={formatDurationLabel( - plannedMinutes, - tDuration("hourUnit"), - tDuration("minuteUnit"), + focusSessionState.plannedMinutes, + "H", + "M", )} - progress={progress} + progress={focusSessionState.progress} + isOvertime={focusSessionState.isOvertime} + overtimeProgress={focusSessionState.overtimeProgress} size="lg" />
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 new file mode 100644 index 00000000..ea734b1c --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts @@ -0,0 +1,246 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; + +import type { TimerSessionControlsHandle } from "@/components/timer/TimerSessionControls"; + +import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; +import { + useChangeStatus, + useCompleteTimer, + useExtendTimer, + useStartTimer, + useStopTimer, +} from "@/api/generated/endpoints/timer/timer"; +import { + useChangeSubtaskStatus, + useChangeTodoStatus, +} from "@/api/generated/endpoints/todo/todo"; +import { useFocusTodo } from "@/app/[locale]/(main)/focus/_queries/use-focus-todo"; +import { convertDateToBadgeText } from "@/app/[locale]/(main)/focus/_utils/date"; +import { useActiveTimer } from "@/hooks/use-active-timer"; +import { useTimerActions } from "@/hooks/use-timer-actions"; +import { useTimerOvertime } from "@/hooks/use-timer-overtime"; +import { useTimerQueryInvalidation } from "@/hooks/use-timer-query-invalidation"; +import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; + +export interface UseFocusSessionOptions { + onMutationError: () => void; + onFeedback: (feedbackText: string | undefined) => void; +} + +export const useFocusSession = ({ + onMutationError, + onFeedback, +}: UseFocusSessionOptions) => { + const queryClient = useQueryClient(); + const timerSessionControlsRef = useRef(null); + const wasTimeUpRef = useRef(false); + + const { data: focusView } = useFocusTodo(); + const { data: activeTimer } = useActiveTimer(); + + const { invalidateActiveTimer, invalidateHomeView, invalidateTimeBoxes } = + useTimerQueryInvalidation(); + const invalidateFocusTodo = () => + queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); + + const { mutate: startTimer } = useStartTimer({ + mutation: { + onSuccess: () => { + invalidateActiveTimer(); + invalidateHomeView(); + invalidateTimeBoxes(); + }, + onError: onMutationError, + }, + }); + const { mutate: changeStatus } = useChangeStatus({ + mutation: { + onSuccess: () => { + invalidateActiveTimer(); + invalidateHomeView(); + invalidateTimeBoxes(); + }, + onError: onMutationError, + }, + }); + const { mutate: extendTimer } = useExtendTimer({ + mutation: { + onSuccess: () => { + invalidateActiveTimer(); + invalidateHomeView(); + invalidateTimeBoxes(); + }, + onError: onMutationError, + }, + }); + const { mutate: completeTimer } = useCompleteTimer({ + mutation: { + onSuccess: (response) => { + onFeedback(response.data?.aiFeedback ?? undefined); + invalidateActiveTimer(); + invalidateFocusTodo(); + invalidateHomeView(); + invalidateTimeBoxes(); + }, + onError: onMutationError, + }, + }); + const { mutate: stopTimer } = useStopTimer({ + mutation: { + onSuccess: (response) => { + onFeedback(response.data?.aiFeedback ?? undefined); + invalidateActiveTimer(); + invalidateFocusTodo(); + invalidateHomeView(); + invalidateTimeBoxes(); + }, + onError: onMutationError, + }, + }); + const { mutate: changeTodoStatus } = useChangeTodoStatus({ + mutation: { + onSuccess: () => { + invalidateFocusTodo(); + invalidateTimeBoxes(); + }, + onError: onMutationError, + }, + }); + const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus({ + mutation: { + onSuccess: () => { + invalidateFocusTodo(); + invalidateTimeBoxes(); + }, + onError: onMutationError, + }, + }); + + const todo = focusView.todo; + const timer = + activeTimer && todo && activeTimer.todoId === todo.todoId + ? activeTimer + : undefined; + const isRunning = timer?.status === "RUNNING"; + const isTimeUp = timer ? timer.remainingSeconds <= 0 : false; + const { overtimeBaseSeconds, markOvertimeStart } = useTimerOvertime(timer); + + useEffect(() => { + if (isTimeUp && !wasTimeUpRef.current) { + timerSessionControlsRef.current?.openEndModal(); + if (timer && isRunning) { + changeStatus({ timerId: timer.timerId, data: { action: "PAUSE" } }); + } + } + wasTimeUpRef.current = isTimeUp; + // isTimeUp이 처음 true가 되는 전환 시점에만 실행되어야 하므로 다른 값은 의도적으로 의존성에서 제외한다 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isTimeUp]); + + const { + handleTogglePlay, + handleExtendTimer, + handleCompleteTimer, + handleStopTimer, + } = useTimerActions({ + timer, + isRunning, + isTimeUp, + dateKey: focusView.date, + markOvertimeStart, + changeStatus, + extendTimer, + completeTimer, + stopTimer, + changeTodoStatus, + onNoTimer: () => { + if (todo) startTimer({ todoId: todo.todoId }); + }, + }); + + const handleToggleCompleted = (completed: boolean) => { + if (!todo) return; + + if (completed && timer) { + timerSessionControlsRef.current?.openStopModal(); + return; + } + + changeTodoStatus({ + todoId: todo.todoId, + data: { isCompleted: completed, date: focusView.date }, + }); + }; + + const handleToggleSubtaskCompleted = ( + subtaskId: number, + completed: boolean, + ) => { + if (!todo) return; + + changeSubtaskStatus({ + todoId: todo.todoId, + subtaskId, + data: { isCompleted: completed }, + }); + }; + + 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 actualMinutes = convertDurationToMinutes(timer?.elapsedSeconds ?? 0); + + return { + focusSessionState: { + focusView, + todo, + timerSessionControlsRef, + isRunning, + isTimeUp, + today, + dateText, + plannedSeconds, + remainingSeconds, + progress, + isOvertime, + overtimeProgress, + plannedMinutes, + actualMinutes, + }, + focusSessionActions: { + onTogglePlay: handleTogglePlay, + onExtend: handleExtendTimer, + onComplete: handleCompleteTimer, + onStop: handleStopTimer, + onToggleCompleted: handleToggleCompleted, + onToggleSubtaskCompleted: handleToggleSubtaskCompleted, + }, + }; +}; diff --git a/apps/timo-web/app/[locale]/(main)/focus/_queries/use-active-timer.ts b/apps/timo-web/app/[locale]/(main)/focus/_queries/use-active-timer.ts deleted file mode 100644 index 8ed8920a..00000000 --- a/apps/timo-web/app/[locale]/(main)/focus/_queries/use-active-timer.ts +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; - -import { - getActiveTimer, - getGetActiveTimerQueryKey, -} from "@/api/generated/endpoints/timer/timer"; -import { activeTimerSchema } from "@/app/[locale]/(main)/focus/_types/timer-type"; - -export const useActiveTimer = () => - 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, - }); diff --git a/apps/timo-web/app/[locale]/(main)/focus/_types/task-type.ts b/apps/timo-web/app/[locale]/(main)/focus/_types/task-type.ts index ee828c6d..5b9b16e7 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_types/task-type.ts +++ b/apps/timo-web/app/[locale]/(main)/focus/_types/task-type.ts @@ -8,9 +8,18 @@ export const focusTaskSubtaskSchema = z.object({ export const focusTaskSchema = z.object({ todoId: z.number(), + // TODO: 실제 응답이 ICON_1~8 코드가 아닌 이모지 문자열이라, 렌더링 방식이 정해지기 전까지는 검증만 하고 화면에는 쓰지 않는다 + icon: z + .string() + .nullish() + .transform((v) => v ?? undefined), title: z.string(), completed: z.boolean(), durationSeconds: z.number().optional(), + memo: z + .string() + .nullish() + .transform((v) => v ?? undefined), subtasks: z.array(focusTaskSubtaskSchema), }); diff --git a/apps/timo-web/components/layout/sidebar/time/TimeboxPanel.tsx b/apps/timo-web/components/layout/sidebar/time/TimeboxPanel.tsx index 605b8615..df34b929 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimeboxPanel.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimeboxPanel.tsx @@ -1,18 +1,27 @@ "use client"; +import { cn } from "@repo/timo-design-system/utils"; import { useEffect, useState } from "react"; +import { useActiveTimer } from "@/hooks/use-active-timer"; +import { useTimeBoxes } from "@/queries/use-time-boxes"; +import { formatDateKey } from "@/utils/date/date"; import { getHourLabel } from "@/utils/date/get-hour-label"; +import { convertDurationToTimeText } from "@/utils/duration/convert-duration-to-time-text"; const HOURS_IN_DAY = 24; const HOUR_LINES = HOURS_IN_DAY + 1; -const ROW_HEIGHT = 30; +const ROW_HEIGHT = 50; const CLOCK_TICK_INTERVAL_MS = 30_000; +const MIN_MINUTES_FOR_LABEL = 30; export interface TimeboxPanelProps { currentTime?: Date; } +const timeToOffset = (date: Date) => + (date.getHours() + date.getMinutes() / 60) * ROW_HEIGHT; + export const TimeboxPanel = ({ currentTime }: TimeboxPanelProps) => { const [now, setNow] = useState(() => currentTime ?? new Date()); @@ -27,8 +36,10 @@ export const TimeboxPanel = ({ currentTime }: TimeboxPanelProps) => { }, [currentTime]); const time = currentTime ?? now; - const currentMinuteRatio = time.getMinutes() / 60; - const currentTimeOffset = (time.getHours() + currentMinuteRatio) * ROW_HEIGHT; + const currentTimeOffset = timeToOffset(time); + + const { data: timeBoxes } = useTimeBoxes(formatDateKey(time)); + const { data: activeTimer } = useActiveTimer(); return (
@@ -47,6 +58,64 @@ export const TimeboxPanel = ({ currentTime }: TimeboxPanelProps) => { ))} + {timeBoxes?.map((timeBox) => { + // 현재 활성 타이머와 같은 timerId(=같은 투두)면, 이전에 일시정지로 끊긴 구간이라도 + // "진행 중인 투두"로 보고 진한 파랑으로 표시한다. + const isRelatedToActiveTimer = + timeBox.timerId === activeTimer?.timerId && + timeBox.todoId === activeTimer?.todoId; + const isGrowing = !timeBox.endedAt; + + const start = new Date(timeBox.startedAt); + const end = timeBox.endedAt ? new Date(timeBox.endedAt) : time; + + const startOffset = timeToOffset(start); + const endOffset = timeToOffset(end); + const height = Math.max(0, endOffset - startOffset); + const durationMinutes = Math.max( + 0, + (end.getTime() - start.getTime()) / 60_000, + ); + const showLabel = durationMinutes >= MIN_MINUTES_FOR_LABEL; + + return ( +
+ {showLabel && ( +
+ + {timeBox.todoName} + + + {convertDurationToTimeText(durationMinutes * 60)} + +
+ )} +
+ ); + })} +