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 1d950639..8c7f75f0 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 @@ -8,7 +8,6 @@ import type { HomeViewFilter } from "@/app/[locale]/(main)/(with-time-sidebar)/h 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, @@ -17,6 +16,7 @@ import { useHomeTodosByDate } from "@/app/[locale]/(main)/(with-time-sidebar)/ho 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 { StopCompleteModalContainer } from "@/containers/timer/StopCompleteModalContainer"; import { DetailTodoModalContainer } from "@/containers/todo-modal/detail/DetailTodoModalContainer"; import { DndSortableListProvider } from "@/providers/dnd/DndSortableListProvider"; import { formatDateKey } from "@/utils/date/date"; @@ -156,6 +156,9 @@ export const HomeTodoContainer = () => { onTogglePlay={() => handleTogglePlay(dateKey, todo.todoId) } + onToggleCompleted={(completed) => + handleToggleCompleted(dateKey, todo.todoId, completed) + } onDelete={() => handleDeleteTodo(dateKey, todo.todoId)} > {(openDetailTodoModal) => ( @@ -204,7 +207,7 @@ export const HomeTodoContainer = () => { ); })} - { + queryClient.invalidateQueries({ + queryKey: getGetTodoDetailQueryKey(todoId, { date: dateKey }), + }); + }; const handleToggleCompleted = ( dateKey: string, @@ -100,6 +106,7 @@ export const useHomeTodosByDate = ( onSuccess: () => { invalidateHomeAndFocus(); invalidateTimeBoxes(); + invalidateTodoDetail(dateKey, todoId); }, onError: () => { setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); @@ -124,7 +131,12 @@ export const useHomeTodosByDate = ( })); changeTodoStatus( { todoId, data: { isCompleted: true, date: dateKey } }, - { onSuccess: invalidateHomeAndFocus }, + { + onSuccess: () => { + invalidateHomeAndFocus(); + invalidateTodoDetail(dateKey, todoId); + }, + }, ); }, }, @@ -141,10 +153,15 @@ export const useHomeTodosByDate = ( } if (activeTimer && activeTimer.todoId === todoId) { - changeStatus({ - timerId: activeTimer.timerId, - data: { action: activeTimer.status === "RUNNING" ? "PAUSE" : "RESUME" }, - }); + changeStatus( + { + timerId: activeTimer.timerId, + data: { + action: activeTimer.status === "RUNNING" ? "PAUSE" : "RESUME", + }, + }, + { onSuccess: () => invalidateTodoDetail(dateKey, todoId) }, + ); return; } @@ -154,7 +171,10 @@ export const useHomeTodosByDate = ( return; } - startTimer({ todoId }); + startTimer( + { todoId }, + { onSuccess: () => invalidateTodoDetail(dateKey, todoId) }, + ); }; const handleToggleSubtaskCompleted = ( @@ -177,6 +197,7 @@ export const useHomeTodosByDate = ( onSuccess: () => { invalidateHomeAndFocus(); invalidateTimeBoxes(); + invalidateTodoDetail(dateKey, todoId); }, onError: () => { setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoCardContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoCardContainer.tsx index dad74d65..e222ec28 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoCardContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoCardContainer.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; +import type { TodoTimerStatusTypes } from "@/api/common/todo-schema"; import type { ReactNode } from "react"; import { @@ -12,74 +13,47 @@ import { export interface TodayTodoCardContainerProps { title: string; - isDone: boolean; + isCompleted: boolean; subTodos: SubTodo[]; toolbar: TodayTodoCardToolbar; - timerStatus: "RUNNING" | "PAUSED" | "STOPPED"; + timerStatus: TodoTimerStatusTypes; icon?: ReactNode; onCardClick?: () => void; - onCheck?: () => void; - onPlay?: () => void; + onToggleCompleted?: (completed: boolean) => void; + onTogglePlay?: () => void; onSubTodoCheck?: (id: number) => void; } export const TodayTodoCardContainer = ({ title, - isDone: initialIsDone, + isCompleted, icon, - subTodos: initialSubTodos, + subTodos, toolbar, timerStatus, onCardClick, - onCheck, - onPlay, + onToggleCompleted, + onTogglePlay, onSubTodoCheck, }: TodayTodoCardContainerProps) => { const [isHovered, setIsHovered] = useState(false); - const [isDone, setIsDone] = useState(initialIsDone); - const [subTodos, setSubTodos] = useState(initialSubTodos); const isPlaying = timerStatus === "RUNNING"; - const isDimmed = isDone && !isHovered; - - const handleCheck = () => { - const next = !isDone; - setIsDone(next); - if (next) { - setSubTodos((prev) => prev.map((s) => ({ ...s, isDone: true }))); - if (isPlaying) { - onPlay?.(); - } - } - onCheck?.(); - }; - - const handlePlay = () => { - if (!isDone) { - onPlay?.(); - } - }; - - const handleSubTodoCheck = (id: number) => { - setSubTodos((prev) => - prev.map((s) => (s.id === id ? { ...s, isDone: !s.isDone } : s)), - ); - onSubTodoCheck?.(id); - }; + const isDimmed = isCompleted && !isHovered; return ( onToggleCompleted?.(!isCompleted)} + onPlay={() => onTogglePlay?.()} + onSubTodoCheck={(id) => onSubTodoCheck?.(id)} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} /> diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx index bcada296..6f973645 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx @@ -1,6 +1,8 @@ "use client"; import { IconGraphic, TODO_ICON_VALUES } from "@repo/timo-design-system/ui"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; import type { TodoIconValue } from "@repo/timo-design-system/ui"; @@ -8,8 +10,11 @@ import { TodayDateHeaderContainer } from "@/app/[locale]/(main)/(with-time-sideb import { TodayTodoCardContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoCardContainer"; import { useTodayTodoList } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList"; import { useToday } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_queries/use-today"; +import { AnimatedToast } from "@/components/toast/AnimatedToast"; +import { StopCompleteModalContainer } from "@/containers/timer/StopCompleteModalContainer"; import { DetailTodoModalContainer } from "@/containers/todo-modal/detail/DetailTodoModalContainer"; import { formatDate } from "@/utils/date/date"; +import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; import { convertDurationToTimeText } from "@/utils/duration/convert-duration-to-time-text"; const renderTodoIcon = (icon: string | undefined) => { @@ -24,18 +29,53 @@ const renderTodoIcon = (icon: string | undefined) => { }; export const TodayTodoListContainer = () => { + const tToast = useTranslations("Toast"); const { data } = useToday(); + + const [pendingCompleteTodoId, setPendingCompleteTodoId] = useState< + number | null + >(null); + const [pendingCompleteToken, setPendingCompleteToken] = useState< + number | null + >(null); + const [feedbackText, setFeedbackText] = useState(); + const [isTimerRunningToastOpen, setIsTimerRunningToastOpen] = useState(false); + const { todos, - runningTodoId, + activeTimer, handlePlay, - handleCheck, + handleToggleCompleted, handleDelete, handleSubTodoCheck, - } = useTodayTodoList(data.todos); + confirmStopAndComplete, + } = useTodayTodoList(data.todos, { + onNeedStopConfirm: (todoId) => { + setPendingCompleteTodoId(todoId); + setPendingCompleteToken(Date.now()); + }, + onTimerAlreadyRunning: () => setIsTimerRunningToastOpen(true), + onStopFeedback: setFeedbackText, + }); + + const handleConfirmPendingComplete = () => { + if (pendingCompleteTodoId === null) return; + + confirmStopAndComplete(pendingCompleteTodoId); + setPendingCompleteTodoId(null); + }; const completedCount = todos.filter((todo) => todo.completed).length; + const plannedMinutes = activeTimer + ? convertDurationToMinutes( + activeTimer.plannedSeconds + activeTimer.extendedSeconds, + ) + : 0; + const actualMinutes = activeTimer + ? convertDurationToMinutes(activeTimer.elapsedSeconds) + : 0; + return (
{ totalCount={todos.length} />
- {todos.map((todo) => ( - handlePlay(todo.todoId)} - onDelete={() => handleDelete(todo.todoId)} - > - {(openDetailTodoModal) => ( - ({ - id: s.subtaskId, - text: s.content, - isDone: s.completed, - }))} - onCardClick={openDetailTodoModal} - onPlay={() => handlePlay(todo.todoId)} - onCheck={() => handleCheck(todo.todoId)} - onSubTodoCheck={(id) => handleSubTodoCheck(todo.todoId, id)} - /> - )} - - ))} + {todos.map((todo) => { + const isActiveTodo = + activeTimer && activeTimer.todoId === todo.todoId; + const durationSeconds = isActiveTodo + ? activeTimer.plannedSeconds + activeTimer.extendedSeconds + : todo.durationSeconds; + const timerStatus = isActiveTodo + ? activeTimer.status + : todo.timerStatus; + + return ( + handlePlay(todo.todoId)} + onToggleCompleted={(completed) => + handleToggleCompleted(todo.todoId, completed) + } + onDelete={() => handleDelete(todo.todoId)} + > + {(openDetailTodoModal) => ( + ({ + id: s.subtaskId, + text: s.content, + isDone: s.completed, + }))} + onCardClick={openDetailTodoModal} + onTogglePlay={() => handlePlay(todo.todoId)} + onToggleCompleted={(completed) => + handleToggleCompleted(todo.todoId, completed) + } + onSubTodoCheck={(id) => handleSubTodoCheck(todo.todoId, id)} + /> + )} + + ); + })}
+ + + + setIsTimerRunningToastOpen(false)} + message={tToast("timerAlreadyRunning")} + />
); }; 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 4273b63c..29d55a6a 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,50 +1,167 @@ "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"; -export const useTodayTodoList = (initialTodos: TodayTodo[]) => { +import { getGetTodayQueryKey } from "@/api/generated/endpoints/home/home"; +import { + useChangeStatus, + useStartTimer, + useStopTimer, +} from "@/api/generated/endpoints/timer/timer"; +import { + getGetTodoDetailQueryKey, + useChangeTodoStatus, +} from "@/api/generated/endpoints/todo/todo"; +import { useActiveTimer } from "@/hooks/use-active-timer"; +import { useTimerQueryInvalidation } from "@/hooks/use-timer-query-invalidation"; +import { useTimeSidebarStore } from "@/stores/time-sidebar/useTimeSidebarStore"; + +export interface UseTodayTodoListOptions { + onNeedStopConfirm: (todoId: number) => void; + onTimerAlreadyRunning: () => void; + onStopFeedback: (feedbackText: string | undefined) => void; +} + +export const useTodayTodoList = ( + initialTodos: TodayTodo[], + { + onNeedStopConfirm, + onTimerAlreadyRunning, + onStopFeedback, + }: UseTodayTodoListOptions, +) => { const [todos, setTodos] = useState(initialTodos); + const openTimerPanel = useTimeSidebarStore((state) => state.openTimerPanel); + const queryClient = useQueryClient(); + const { data: activeTimer } = useActiveTimer(); + const { mutate: changeTodoStatus } = useChangeTodoStatus(); + const { mutate: stopTimer } = useStopTimer(); + const { invalidateTimerState, invalidateTimeBoxes } = + useTimerQueryInvalidation(); + + const { mutate: startTimer } = useStartTimer({ + mutation: { onSuccess: invalidateTimerState }, + }); + const { mutate: changeStatus } = useChangeStatus({ + mutation: { onSuccess: invalidateTimerState }, + }); useEffect(() => { setTodos(initialTodos); }, [initialTodos]); - const runningTodoId = - todos.find((todo) => todo.timerStatus === "RUNNING")?.todoId ?? null; - - const handlePlay = (todoId: number) => { - // TODO: API + const updateTodo = ( + todoId: number, + updater: (todo: TodayTodo) => TodayTodo, + ) => { setTodos((prev) => - prev.map((todo) => ({ - ...todo, - timerStatus: - todo.todoId === todoId && todo.timerStatus !== "RUNNING" - ? "RUNNING" - : "STOPPED", - })), + prev.map((todo) => (todo.todoId === todoId ? updater(todo) : todo)), ); }; - const handleCheck = (todoId: number) => { - // TODO: API - setTodos((prev) => { - const updated: TodayTodo[] = prev.map((todo) => - todo.todoId === todoId - ? { - ...todo, - completed: !todo.completed, - timerStatus: "STOPPED" as const, - } - : todo, - ); - return [...updated].sort( - (a, b) => Number(a.completed) - Number(b.completed), - ); + const invalidateTodayView = () => { + queryClient.invalidateQueries({ queryKey: getGetTodayQueryKey() }); + }; + const invalidateTodoDetail = (todoId: number, dateKey: string) => { + queryClient.invalidateQueries({ + queryKey: getGetTodoDetailQueryKey(todoId, { date: dateKey }), }); }; + const handleToggleCompleted = (todoId: number, completed: boolean) => { + if (completed && activeTimer?.todoId === todoId) { + onNeedStopConfirm(todoId); + return; + } + + const dateKey = todos.find((todo) => todo.todoId === todoId)?.date; + if (!dateKey) return; + + const previous = todos; + updateTodo(todoId, (todo) => ({ ...todo, completed })); + + changeTodoStatus( + { todoId, data: { isCompleted: completed, date: dateKey } }, + { + onSuccess: () => { + invalidateTodayView(); + invalidateTimeBoxes(); + invalidateTodoDetail(todoId, dateKey); + }, + onError: () => { + setTodos(previous); + }, + }, + ); + }; + + const confirmStopAndComplete = (todoId: number) => { + if (!activeTimer) return; + + const dateKey = todos.find((todo) => todo.todoId === todoId)?.date; + if (!dateKey) return; + + stopTimer( + { timerId: activeTimer.timerId }, + { + onSuccess: (response) => { + onStopFeedback(response.data?.aiFeedback ?? undefined); + invalidateTimerState(); + + updateTodo(todoId, (todo) => ({ ...todo, completed: true })); + changeTodoStatus( + { todoId, data: { isCompleted: true, date: dateKey } }, + { + onSuccess: () => { + invalidateTodayView(); + invalidateTodoDetail(todoId, dateKey); + }, + }, + ); + }, + }, + ); + }; + + const handlePlay = (todoId: number) => { + const dateKey = todos.find((todo) => todo.todoId === todoId)?.date; + if (!dateKey) return; + + const willRun = + todos.find((todo) => todo.todoId === todoId)?.timerStatus !== "RUNNING"; + + if (willRun) { + openTimerPanel(); + } + + if (activeTimer && activeTimer.todoId === todoId) { + changeStatus( + { + timerId: activeTimer.timerId, + data: { + action: activeTimer.status === "RUNNING" ? "PAUSE" : "RESUME", + }, + }, + { onSuccess: () => invalidateTodoDetail(todoId, dateKey) }, + ); + return; + } + + // 다른 투두의 타이머가 이미 실행/일시정지 중이면 새 타이머를 시작할 수 없다(409) + if (activeTimer) { + onTimerAlreadyRunning(); + return; + } + + startTimer( + { todoId }, + { onSuccess: () => invalidateTodoDetail(todoId, dateKey) }, + ); + }; + const handleDelete = (todoId: number) => { // TODO: API setTodos((prev) => prev.filter((todo) => todo.todoId !== todoId)); @@ -70,10 +187,11 @@ export const useTodayTodoList = (initialTodos: TodayTodo[]) => { return { todos, - runningTodoId, + activeTimer, handlePlay, - handleCheck, + handleToggleCompleted, handleDelete, handleSubTodoCheck, + confirmStopAndComplete, }; }; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/page.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/page.tsx index 0c00367c..4fffe122 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/page.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/page.tsx @@ -6,10 +6,12 @@ export default function TodayPage() { return (
-
- - - +
+
+ + + +
); diff --git a/apps/timo-web/app/[locale]/(main)/today/_components/TodayTodoCard.tsx b/apps/timo-web/app/[locale]/(main)/today/_components/TodayTodoCard.tsx index c5110b58..6003adc2 100644 --- a/apps/timo-web/app/[locale]/(main)/today/_components/TodayTodoCard.tsx +++ b/apps/timo-web/app/[locale]/(main)/today/_components/TodayTodoCard.tsx @@ -96,7 +96,7 @@ export const TodayTodoCard = ({ onClick={onCardClick} onKeyDown={onCardClick ? handleCardKeyDown : undefined} className={cn( - "border-timo-gray-500 flex w-full flex-col gap-1 rounded-[4px] border px-5 py-4", + "border-timo-gray-500 flex min-w-80 flex-col gap-1 rounded-[4px] border px-5 py-4", style.card, onCardClick && "cursor-pointer", )} @@ -105,6 +105,7 @@ export const TodayTodoCard = ({
diff --git a/apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx b/apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx index 85de321b..dad126c1 100644 --- a/apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx +++ b/apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx @@ -41,6 +41,7 @@ export interface DetailTodoModalContentProps { onExited: () => void; todo: TodoDetailResponse; onTogglePlay: () => void; + onToggleCompleted: (completed: boolean) => void; onDelete: () => void; onUpdate: (data: TodoUpdateRequest) => void; } @@ -51,6 +52,7 @@ export const DetailTodoModalContent = ({ onExited, todo, onTogglePlay, + onToggleCompleted, onDelete, onUpdate, }: DetailTodoModalContentProps) => { @@ -176,7 +178,9 @@ export const DetailTodoModalContent = ({ const handleSubtaskCompletedChange = (id: number, completed: boolean) => { const subtasks = detailTodoForm.changeSubtaskCompleted(id, completed); - updateTodo({ subtasks }); + const updateData = detailTodoForm.buildUpdateRequest({ subtasks }); + if (!updateData.title?.trim()) return; + onUpdate(updateData); }; return ( @@ -220,16 +224,13 @@ export const DetailTodoModalContent = ({
{ - onTogglePlay(); - onClose(); - }} + onToggleCompleted={onToggleCompleted} + onTogglePlay={onTogglePlay} onSubtaskInputChange={detailTodoForm.changeSubtaskInput} onToggleSubtaskCompleted={handleSubtaskCompletedChange} registerSubtaskInputRef={detailTodoForm.registerSubtaskInputRef} diff --git a/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx b/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx index c71235c8..e4a5f676 100644 --- a/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx +++ b/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx @@ -53,11 +53,7 @@ export const DetailTodoTaskFields = ({
- +