diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/todo-card/HomeTodoCard.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/todo-card/HomeTodoCard.tsx index f0497c0b..b6f5dbcd 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/todo-card/HomeTodoCard.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/todo-card/HomeTodoCard.tsx @@ -22,7 +22,7 @@ import type { TodoPriorityTypes, TodoTimerStatusTypes, } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; -import type { KeyboardEvent, MouseEvent } from "react"; +import type { KeyboardEvent, MouseEvent, PointerEvent } from "react"; import { convertDurationToTimeText } from "@/utils/todo/todo-time"; @@ -103,6 +103,17 @@ export const HomeTodoCard = ({ onClickTodo(); }; + const stopInteractiveEvent = ( + event: MouseEvent | PointerEvent, + ) => { + event.stopPropagation(); + }; + + const handlePlayClick = (event: MouseEvent) => { + stopInteractiveEvent(event); + onTogglePlay(); + }; + const titleRow = (
@@ -120,7 +131,8 @@ export const HomeTodoCard = ({ variant={isRunning ? "stop" : "play"} size="sm" disabled={isCompleted} - onClick={onTogglePlay} + onClick={handlePlayClick} + onPointerDown={stopInteractiveEvent} > {isCompleted ? ( 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 28ec4ab8..1d950639 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 @@ -17,7 +17,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 { DetailTodoModalContainer } from "@/components/todo-modal/detail/DetailTodoModalContainer"; +import { DetailTodoModalContainer } from "@/containers/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"; @@ -151,7 +151,8 @@ export const HomeTodoContainer = () => { return ( handleTogglePlay(dateKey, todo.todoId) } diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeDayHeaderContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeDayHeaderContainer.tsx index 9d2fb32a..59e618bd 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeDayHeaderContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeDayHeaderContainer.tsx @@ -6,7 +6,7 @@ import type { ApiDayOfWeek } from "@/app/[locale]/(main)/(with-time-sidebar)/hom import { DateInformation } from "@/app/[locale]/(main)/(with-time-sidebar)/_components/DateInformation"; import { convertDateToDateText } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date"; -import { CreateTodoModalContainer } from "@/components/todo-modal/create/CreateTodoModalContainer"; +import { CreateTodoModalContainer } from "@/containers/todo-modal/create/CreateTodoModalContainer"; import { getToday, parseDateKey } from "@/utils/date/date"; export interface HomeDayHeaderContainerProps { diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayDateHeaderContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayDateHeaderContainer.tsx index 36237f0a..562bdf6d 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayDateHeaderContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayDateHeaderContainer.tsx @@ -6,7 +6,7 @@ import { DateInformation } from "@/app/[locale]/(main)/(with-time-sidebar)/_comp import { convertDateToDateText } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date"; import { useCreateTodoSubmit } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/todo-modal/use-create-todo-submit"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; -import { CreateTodoModalContainer } from "@/components/todo-modal/create/CreateTodoModalContainer"; +import { CreateTodoModalContainer } from "@/containers/todo-modal/create/CreateTodoModalContainer"; import { getToday } from "@/utils/date/date"; import { getDayOfWeekKey } from "@/utils/date/get-day-of-week-key"; 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 5bd3ea74..bcada296 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 @@ -8,7 +8,7 @@ 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 { DetailTodoModalContainer } from "@/components/todo-modal/detail/DetailTodoModalContainer"; +import { DetailTodoModalContainer } from "@/containers/todo-modal/detail/DetailTodoModalContainer"; import { formatDate } from "@/utils/date/date"; import { convertDurationToTimeText } from "@/utils/duration/convert-duration-to-time-text"; @@ -46,7 +46,8 @@ export const TodayTodoListContainer = () => { {todos.map((todo) => ( handlePlay(todo.todoId)} onDelete={() => handleDelete(todo.todoId)} > diff --git a/apps/timo-web/components/todo-modal/create/CreateTodoModalContent.tsx b/apps/timo-web/components/todo-modal/create/CreateTodoModalContent.tsx index f5511680..a09e600b 100644 --- a/apps/timo-web/components/todo-modal/create/CreateTodoModalContent.tsx +++ b/apps/timo-web/components/todo-modal/create/CreateTodoModalContent.tsx @@ -15,12 +15,12 @@ import { AnimatedToast } from "@/components/toast/AnimatedToast"; import { TodoIconField } from "@/components/todo-modal/common/TodoIconField"; import { CreateTodoMemoField } from "@/components/todo-modal/create/CreateTodoMemoField"; import { CreateTodoTaskFields } from "@/components/todo-modal/create/CreateTodoTaskFields"; -import { useIconField } from "@/hooks/todo-modal/use-icon-field"; -import { useRepeatField } from "@/hooks/todo-modal/use-repeat-field"; -import { useSubtaskField } from "@/hooks/todo-modal/use-subtask-field"; -import { useTagField } from "@/hooks/todo-modal/use-tag-field"; -import { useTimeField } from "@/hooks/todo-modal/use-time-field"; -import { useTitleField } from "@/hooks/todo-modal/use-title-field"; +import { useTagField } from "@/hooks/todo-modal/common/use-tag-field"; +import { useIconField } from "@/hooks/todo-modal/create/use-icon-field"; +import { useRepeatField } from "@/hooks/todo-modal/create/use-repeat-field"; +import { useSubtaskField } from "@/hooks/todo-modal/create/use-subtask-field"; +import { useTimeField } from "@/hooks/todo-modal/create/use-time-field"; +import { useTitleField } from "@/hooks/todo-modal/create/use-title-field"; import { formatDateKey, formatShortDateLabel } from "@/utils/date/date"; const createDefaultValues = (defaultDate?: Date): CreateTodoRequest => ({ diff --git a/apps/timo-web/components/todo-modal/detail/DetailTodoMemoField.tsx b/apps/timo-web/components/todo-modal/detail/DetailTodoMemoField.tsx index ca36ff68..c2cedfed 100644 --- a/apps/timo-web/components/todo-modal/detail/DetailTodoMemoField.tsx +++ b/apps/timo-web/components/todo-modal/detail/DetailTodoMemoField.tsx @@ -5,6 +5,7 @@ export interface DetailTodoMemoFieldProps { placeholder: string; onChange: (value: string) => void; maxLength: number; + disabled?: boolean; } export const DetailTodoMemoField = ({ @@ -12,6 +13,7 @@ export const DetailTodoMemoField = ({ placeholder, onChange, maxLength, + disabled = false, }: DetailTodoMemoFieldProps) => { const textareaRef = useRef(null); @@ -30,7 +32,8 @@ export const DetailTodoMemoField = ({ placeholder={placeholder} maxLength={maxLength} onChange={(event) => onChange(event.target.value)} - className="typo-body-r-14 text-timo-black placeholder:text-timo-gray-700 min-h-20 w-full resize-none overflow-hidden p-1 outline-none" + disabled={disabled} + className="typo-headline-r-14 text-timo-gray-700 min-h-20 w-full resize-none overflow-hidden p-1 outline-none" /> ); }; diff --git a/apps/timo-web/components/todo-modal/detail/DetailTodoModalContainer.tsx b/apps/timo-web/components/todo-modal/detail/DetailTodoModalContainer.tsx deleted file mode 100644 index 823509d6..00000000 --- a/apps/timo-web/components/todo-modal/detail/DetailTodoModalContainer.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { overlay } from "overlay-kit"; - -import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; -import type { ReactNode } from "react"; - -import { DetailTodoModalContent } from "@/components/todo-modal/detail/DetailTodoModalContent"; - -export interface DetailTodoModalContainerProps { - todo: Todo; - onTogglePlay: () => void; - onDelete: () => void; - children: (openDetailTodoModal: () => void) => ReactNode; -} - -export const DetailTodoModalContainer = ({ - todo, - onTogglePlay, - onDelete, - children, -}: DetailTodoModalContainerProps) => { - const openDetailTodoModal = () => { - overlay.open(({ isOpen, close, unmount }) => ( - - )); - }; - - return <>{children(openDetailTodoModal)}; -}; diff --git a/apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx b/apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx index 05c398ee..85de321b 100644 --- a/apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx +++ b/apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx @@ -1,12 +1,18 @@ -"use client"; - import { DeleteIcon, TrashOnIcon } from "@repo/timo-design-system/icons"; import { TodoToolbar } from "@repo/timo-design-system/ui"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; -import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; -import type { TimeSelection } from "@repo/timo-design-system/ui"; +import type { + TodoDetailResponse, + TodoUpdateRequest, +} from "@/api/generated/models"; +import type { + PriorityLevel, + RepeatFrequency, + TimeSelection, + TodoIconValue, +} from "@repo/timo-design-system/ui"; import { OverlayModal } from "@/components/modal/OverlayModal"; import { TodoIconField } from "@/components/todo-modal/common/TodoIconField"; @@ -15,19 +21,28 @@ import { DetailTodoTaskFields } from "@/components/todo-modal/detail/DetailTodoT import { DETAIL_TODO_TIME_OPTIONS, DETAIL_TODO_WEEKDAY_IDS, + type DetailTodoUpdateRequestOverrides, useDetailTodoForm, -} from "@/hooks/todo-modal/use-detail-todo-form"; +} from "@/hooks/todo-modal/detail/use-detail-todo-form"; import { formatShortDateLabel } from "@/utils/date/date"; const DETAIL_TODO_MEMO_MAX_LENGTH = 300; +const TEXT_UPDATE_DEBOUNCE_MS = 3000; +type DetailTodoWeekdayId = (typeof DETAIL_TODO_WEEKDAY_IDS)[number]; + +const isDetailTodoWeekdayId = ( + dayOfWeek: string, +): dayOfWeek is DetailTodoWeekdayId => + (DETAIL_TODO_WEEKDAY_IDS as readonly string[]).includes(dayOfWeek); export interface DetailTodoModalContentProps { isOpen: boolean; onClose: () => void; onExited: () => void; - todo: Todo; + todo: TodoDetailResponse; onTogglePlay: () => void; onDelete: () => void; + onUpdate: (data: TodoUpdateRequest) => void; } export const DetailTodoModalContent = ({ @@ -37,6 +52,7 @@ export const DetailTodoModalContent = ({ todo, onTogglePlay, onDelete, + onUpdate, }: DetailTodoModalContentProps) => { const t = useTranslations("Home.detailModal"); const tCreateModal = useTranslations("Home.createModal"); @@ -44,24 +60,123 @@ export const DetailTodoModalContent = ({ const detailTodoForm = useDetailTodoForm({ todo }); const [selectedTime, setSelectedTime] = useState(); const [isIconPanelOpen, setIsIconPanelOpen] = useState(false); + const canUpdateTodo = todo.timerStatus === "STOPPED"; + const dateNumber = detailTodoForm.date.getDate(); + const dayOfWeek = isDetailTodoWeekdayId(todo.dayOfWeek) + ? todo.dayOfWeek + : "MON"; const weekdays = DETAIL_TODO_WEEKDAY_IDS.map((weekdayId) => ({ id: weekdayId, label: tCommon(`weekday.${weekdayId}`), })); + const latestOnUpdateRef = useRef(onUpdate); + const latestBuildUpdateRequestRef = useRef(detailTodoForm.buildUpdateRequest); + const didStartTextUpdateRef = useRef(false); + const textUpdateSignature = useMemo( + () => + JSON.stringify({ + title: detailTodoForm.title, + memo: detailTodoForm.memo, + subtasks: detailTodoForm.subtaskInputs.map((subtask) => ({ + id: subtask.id, + subtaskId: subtask.subtaskId, + value: subtask.value, + })), + }), + [detailTodoForm.memo, detailTodoForm.subtaskInputs, detailTodoForm.title], + ); + + useEffect(() => { + latestOnUpdateRef.current = onUpdate; + }, [onUpdate]); + + useEffect(() => { + latestBuildUpdateRequestRef.current = detailTodoForm.buildUpdateRequest; + }, [detailTodoForm.buildUpdateRequest]); + + useEffect(() => { + if (!isOpen) return; + if (!canUpdateTodo) return; + + if (!didStartTextUpdateRef.current) { + didStartTextUpdateRef.current = true; + return; + } + + if (!detailTodoForm.title.trim()) return; + + const updateTimer = window.setTimeout(() => { + latestOnUpdateRef.current(latestBuildUpdateRequestRef.current()); + }, TEXT_UPDATE_DEBOUNCE_MS); + + return () => window.clearTimeout(updateTimer); + }, [canUpdateTodo, detailTodoForm.title, isOpen, textUpdateSignature]); + + const updateTodo = (overrides: DetailTodoUpdateRequestOverrides = {}) => { + if (!canUpdateTodo) return; - const handleTogglePlay = () => { - onTogglePlay(); - onClose(); + const updateData = detailTodoForm.buildUpdateRequest(overrides); + if (!updateData.title?.trim()) return; + + onUpdate(updateData); + }; + + const handleSelectIcon = (nextIcon: TodoIconValue) => { + detailTodoForm.selectIcon(nextIcon); + updateTodo({ icon: nextIcon }); }; - const handleDelete = () => { - onDelete(); - onClose(); + const handleRemoveIcon = () => { + detailTodoForm.removeIcon(); + updateTodo({ icon: null }); }; const handleSelectTime = (nextTime: TimeSelection) => { setSelectedTime(nextTime); - detailTodoForm.selectTime(nextTime); + const time = detailTodoForm.selectTime(nextTime); + + if (time) updateTodo({ time }); + }; + + const handleDateChange = (nextDate: Date) => { + detailTodoForm.setDate(nextDate); + updateTodo({ date: nextDate }); + }; + + const handleTimeChange = (time: string) => { + detailTodoForm.setTime(time); + updateTodo({ time }); + }; + + const handleSelectPriority = (priority: PriorityLevel) => { + detailTodoForm.setPriority(priority); + updateTodo({ priority }); + }; + + const handleSelectTag = (label: string) => { + const tagId = detailTodoForm.handleSelectTag(label); + + if (tagId !== null) updateTodo({ tagId }); + }; + + const handleRepeatFrequencyChange = (repeatFrequency: RepeatFrequency) => { + detailTodoForm.changeRepeatFrequency(repeatFrequency); + updateTodo({ isRepeatActive: true, repeatFrequency }); + }; + + const handleWeekdayToggle = (weekdayId: string) => { + const selectedWeekdayIds = detailTodoForm.toggleWeekday(weekdayId); + updateTodo({ selectedWeekdayIds }); + }; + + const handleRepeatDayChange = (repeatDay: string) => { + detailTodoForm.setRepeatDay(repeatDay); + updateTodo({ repeatDay }); + }; + + const handleSubtaskCompletedChange = (id: number, completed: boolean) => { + const subtasks = detailTodoForm.changeSubtaskCompleted(id, completed); + updateTodo({ subtasks }); }; return ( @@ -81,108 +196,124 @@ export const DetailTodoModalContent = ({
-

22

+

{dateNumber}

- {tCommon("weekday.MON")} + {tCommon(`weekday.${dayOfWeek}`)}

- setIsIconPanelOpen(true)} - onTogglePanel={() => setIsIconPanelOpen((prev) => !prev)} - onSelectIcon={detailTodoForm.selectIcon} - onRemoveIcon={detailTodoForm.removeIcon} - /> +
+ setIsIconPanelOpen(true)} + onTogglePanel={() => setIsIconPanelOpen((prev) => !prev)} + onSelectIcon={handleSelectIcon} + onRemoveIcon={handleRemoveIcon} + /> +
-
+
{ + onTogglePlay(); + onClose(); + }} onSubtaskInputChange={detailTodoForm.changeSubtaskInput} - onToggleSubtaskCompleted={detailTodoForm.changeSubtaskCompleted} + onToggleSubtaskCompleted={handleSubtaskCompletedChange} registerSubtaskInputRef={detailTodoForm.registerSubtaskInputRef} onSubtaskInputKeyDown={detailTodoForm.handleSubtaskInputKeyDown} />
-
- {}} - hasMemo={todo.hasMemo} - isRepeatActive={detailTodoForm.isRepeatActive} - repeat={{ - frequencyHeading: t("repeatFrequencyHeading"), - detailHeading: tCreateModal("repeatDetailHeading"), - options: [ - { frequency: "DAILY", label: tCreateModal("repeatDaily") }, - { frequency: "WEEKLY", label: tCreateModal("repeatWeekly") }, - { - frequency: "MONTHLY", - label: tCreateModal("repeatMonthly"), +
+
+ {}} + hasMemo={Boolean(todo.memo?.trim())} + isRepeatActive={detailTodoForm.isRepeatActive} + repeat={{ + frequencyHeading: t("repeatFrequencyHeading"), + detailHeading: tCreateModal("repeatDetailHeading"), + options: [ + { frequency: "DAILY", label: tCreateModal("repeatDaily") }, + { + frequency: "WEEKLY", + label: tCreateModal("repeatWeekly"), + }, + { + frequency: "MONTHLY", + label: tCreateModal("repeatMonthly"), + }, + ], + frequency: detailTodoForm.repeatFrequency, + onFrequencyChange: handleRepeatFrequencyChange, + weekly: { + weekdays, + selectedWeekdayIds: detailTodoForm.selectedWeekdayIds, + onWeekdayToggle: handleWeekdayToggle, }, - ], - frequency: detailTodoForm.repeatFrequency, - onFrequencyChange: detailTodoForm.changeRepeatFrequency, - weekly: { - weekdays, - selectedWeekdayIds: detailTodoForm.selectedWeekdayIds, - onWeekdayToggle: detailTodoForm.toggleWeekday, - }, - monthly: { - repeatDayLabel: t("repeatDayLabel"), - repeatDay: detailTodoForm.repeatDay, - onRepeatDayChange: detailTodoForm.setRepeatDay, - }, - }} + monthly: { + repeatDayLabel: t("repeatDayLabel"), + repeatDay: detailTodoForm.repeatDay, + onRepeatDayChange: handleRepeatDayChange, + }, + }} + /> + +
+ + -
- -
diff --git a/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx b/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx index f0517b7a..c71235c8 100644 --- a/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx +++ b/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx @@ -5,8 +5,8 @@ import { } from "@repo/timo-design-system/icons"; import { Checkbox, PlayButton } from "@repo/timo-design-system/ui"; -import type { TodoTimerStatusTypes } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; -import type { DetailTodoSubtaskInput } from "@/hooks/todo-modal/use-detail-subtask-field"; +import type { TodoDetailResponseTimerStatus } from "@/api/generated/models"; +import type { DetailTodoSubtaskInput } from "@/hooks/todo-modal/detail/use-detail-subtask-field"; import type { KeyboardEvent } from "react"; const resizeTextarea = (element: HTMLTextAreaElement | null) => { @@ -18,7 +18,8 @@ const resizeTextarea = (element: HTMLTextAreaElement | null) => { export interface DetailTodoTaskFieldsProps { titleValue: string; isCompleted: boolean; - timerStatus: TodoTimerStatusTypes; + disabled?: boolean; + timerStatus: TodoDetailResponseTimerStatus; subtaskInputs: DetailTodoSubtaskInput[]; onTitleChange: (value: string) => void; onToggleCompleted: (completed: boolean) => void; @@ -37,6 +38,7 @@ export interface DetailTodoTaskFieldsProps { export const DetailTodoTaskFields = ({ titleValue, isCompleted, + disabled = false, timerStatus, subtaskInputs, onTitleChange, @@ -51,7 +53,11 @@ export const DetailTodoTaskFields = ({
- +