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 e19edb94..3e98a59b 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 @@ -106,7 +106,7 @@ export const HomeTodoCard = ({ }; const stopInteractiveEvent = ( - event: MouseEvent | PointerEvent, + event: MouseEvent | PointerEvent, ) => { event.stopPropagation(); }; @@ -129,24 +129,29 @@ export const HomeTodoCard = ({ {title}

- - {isCompleted ? ( - - ) : isRunning ? ( - - ) : isPlayHighlighted ? ( - - ) : ( - - )} - + + {isCompleted ? ( + + ) : isRunning ? ( + + ) : isPlayHighlighted ? ( + + ) : ( + + )} + + ); diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_components/TodayTodoCard.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_components/TodayTodoCard.tsx index f4b10e0f..52ae7af6 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_components/TodayTodoCard.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_components/TodayTodoCard.tsx @@ -126,7 +126,11 @@ export const TodayTodoCard = ({ size="lg" disabled={isDone} active={isPlayHighlighted} - onClick={onPlay} + onClick={(event) => { + stopPropagation(event); + onPlay(); + }} + onPointerDown={stopPropagation} > {isDone ? ( diff --git a/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsLogoutModalContainer.tsx b/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsLogoutModalContainer.tsx index c3d5fa7c..8fd210fc 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsLogoutModalContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsLogoutModalContainer.tsx @@ -41,9 +41,7 @@ export const SettingsLogoutModalContainer = ({ {tSettings.rich("profile.logoutConfirmTitle", { - red: (chunks) => ( - {chunks} - ), + red: (chunks) => {chunks}, })} diff --git a/apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx b/apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx index 9d4f162c..8ce10f77 100644 --- a/apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx +++ b/apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx @@ -1,18 +1,13 @@ import { DeleteIcon, TrashOnIcon } from "@repo/timo-design-system/icons"; import { TodoToolbar } from "@repo/timo-design-system/ui"; import { useTranslations } from "next-intl"; -import { useEffect, useMemo, useRef, useState } from "react"; import type { TodoDetailResponse, + TodoDetailResponseTimerStatus, TodoUpdateRequest, } from "@/api/generated/models"; -import type { - PriorityLevel, - RepeatFrequency, - TimeSelection, - TodoIconValue, -} from "@repo/timo-design-system/ui"; +import type { UpdateTodoSubmitHandlers } from "@/hooks/todo-modal/detail/use-update-todo-submit"; import { OverlayModal } from "@/components/modal/OverlayModal"; import { TodoIconField } from "@/components/todo-modal/common/TodoIconField"; @@ -21,13 +16,15 @@ import { DetailTodoTaskFields } from "@/components/todo-modal/detail/DetailTodoT import { DETAIL_TODO_TIME_OPTIONS, DETAIL_TODO_WEEKDAY_IDS, - type DetailTodoUpdateRequestOverrides, useDetailTodoForm, } from "@/hooks/todo-modal/detail/use-detail-todo-form"; +import { useDetailTodoIconSubmit } from "@/hooks/todo-modal/detail/use-detail-todo-icon-submit"; +import { useDetailTodoPatchHandlers } from "@/hooks/todo-modal/detail/use-detail-todo-patch-handlers"; +import { useDetailTodoTextAutoSave } from "@/hooks/todo-modal/detail/use-detail-todo-text-auto-save"; import { formatShortDateLabel } from "@/utils/date/date"; +import { convertApiDurationToClockTimeText } from "@/utils/todo/todo-time"; const DETAIL_TODO_MEMO_MAX_LENGTH = 300; -const TEXT_UPDATE_DEBOUNCE_MS = 3000; type DetailTodoWeekdayId = (typeof DETAIL_TODO_WEEKDAY_IDS)[number]; const isDetailTodoWeekdayId = ( @@ -44,7 +41,11 @@ export interface DetailTodoModalContentProps { onTogglePlay: () => void; onToggleCompleted: (completed: boolean) => void; onDelete: () => void; - onUpdate: (data: TodoUpdateRequest) => void; + onUpdate: ( + data: TodoUpdateRequest, + handlers?: UpdateTodoSubmitHandlers, + ) => void; + timerStatus: TodoDetailResponseTimerStatus; } export const DetailTodoModalContent = ({ @@ -57,14 +58,12 @@ export const DetailTodoModalContent = ({ onToggleCompleted, onDelete, onUpdate, + timerStatus, }: DetailTodoModalContentProps) => { const t = useTranslations("Home.detailModal"); const tCreateModal = useTranslations("Home.createModal"); const tCommon = useTranslations("Common"); 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 @@ -73,128 +72,44 @@ export const DetailTodoModalContent = ({ 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 updateData = detailTodoForm.buildUpdateRequest(overrides); - if (!updateData.title?.trim()) return; - - onUpdate(updateData); - }; - - const handleSelectIcon = (nextIcon: TodoIconValue) => { - detailTodoForm.selectIcon(nextIcon); - updateTodo({ icon: nextIcon }); - }; - - const handleRemoveIcon = () => { - detailTodoForm.removeIcon(); - updateTodo({ icon: null }); - }; - - const handleSelectTime = (nextTime: TimeSelection) => { - setSelectedTime(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); - const updateData = detailTodoForm.buildUpdateRequest({ subtasks }); - if (!updateData.title?.trim()) return; - onUpdate(updateData); + const displayTime = convertApiDurationToClockTimeText(detailTodoForm.time); + const canUpdateTodo = timerStatus === "STOPPED"; + const patchHandlers = useDetailTodoPatchHandlers({ + form: detailTodoForm, + onUpdate, + }); + const iconField = useDetailTodoIconSubmit({ + icon: detailTodoForm.icon, + selectIcon: detailTodoForm.selectIcon, + onUpdate: patchHandlers.updateTodo, + }); + const { submitTextUpdate } = useDetailTodoTextAutoSave({ + isOpen, + title: detailTodoForm.title, + memo: detailTodoForm.memo, + subtasks: detailTodoForm.subtaskInputs, + onUpdate: patchHandlers.updateTodo, + }); + + const handleClose = () => { + submitTextUpdate(); + onClose(); }; return (
-
@@ -210,13 +125,13 @@ export const DetailTodoModalContent = ({
setIsIconPanelOpen(true)} - onTogglePanel={() => setIsIconPanelOpen((prev) => !prev)} - onSelectIcon={handleSelectIcon} - onRemoveIcon={handleRemoveIcon} + onOpenPanel={iconField.handleOpenIconPanel} + onTogglePanel={iconField.handleToggleIconPanel} + onSelectIcon={iconField.handleSelectIcon} + onRemoveIcon={iconField.handleRemoveIcon} />
@@ -228,14 +143,16 @@ export const DetailTodoModalContent = ({ titleValue={detailTodoForm.title} isCompleted={todo.completed} disabled={!canUpdateTodo} - timerStatus={todo.timerStatus} + timerStatus={timerStatus} isPlayHighlighted={isPlayHighlighted} subtaskInputs={detailTodoForm.subtaskInputs} onTitleChange={detailTodoForm.changeTitle} onToggleCompleted={onToggleCompleted} onTogglePlay={onTogglePlay} onSubtaskInputChange={detailTodoForm.changeSubtaskInput} - onToggleSubtaskCompleted={handleSubtaskCompletedChange} + onToggleSubtaskCompleted={ + patchHandlers.handleSubtaskCompletedChange + } registerSubtaskInputRef={detailTodoForm.registerSubtaskInputRef} onSubtaskInputKeyDown={detailTodoForm.handleSubtaskInputKeyDown} /> @@ -252,13 +169,13 @@ export const DetailTodoModalContent = ({ {}} hasMemo={Boolean(todo.memo?.trim())} isRepeatActive={detailTodoForm.isRepeatActive} @@ -292,16 +209,16 @@ export const DetailTodoModalContent = ({ }, ], frequency: detailTodoForm.repeatFrequency, - onFrequencyChange: handleRepeatFrequencyChange, + onFrequencyChange: patchHandlers.handleRepeatFrequencyChange, weekly: { weekdays, selectedWeekdayIds: detailTodoForm.selectedWeekdayIds, - onWeekdayToggle: handleWeekdayToggle, + onWeekdayToggle: patchHandlers.handleWeekdayToggle, }, monthly: { repeatDayLabel: t("repeatDayLabel"), repeatDay: detailTodoForm.repeatDay, - onRepeatDayChange: handleRepeatDayChange, + onRepeatDayChange: patchHandlers.handleRepeatDayChange, }, }} /> diff --git a/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx b/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx index a3e1db31..a1543bce 100644 --- a/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx +++ b/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx @@ -7,7 +7,7 @@ import { Checkbox, PlayButton } from "@repo/timo-design-system/ui"; import type { TodoDetailResponseTimerStatus } from "@/api/generated/models"; import type { DetailTodoSubtaskInput } from "@/hooks/todo-modal/detail/use-detail-subtask-field"; -import type { KeyboardEvent } from "react"; +import type { KeyboardEvent, MouseEvent, PointerEvent } from "react"; const resizeTextarea = (element: HTMLTextAreaElement | null) => { if (!element) return; @@ -51,6 +51,19 @@ export const DetailTodoTaskFields = ({ registerSubtaskInputRef, onSubtaskInputKeyDown, }: DetailTodoTaskFieldsProps) => { + const isRunning = timerStatus === "RUNNING"; + + const stopInteractiveEvent = ( + event: MouseEvent | PointerEvent, + ) => { + event.stopPropagation(); + }; + + const handlePlayClick = (event: MouseEvent) => { + stopInteractiveEvent(event); + onTogglePlay(); + }; + return (
@@ -71,15 +84,16 @@ export const DetailTodoTaskFields = ({
{isCompleted ? ( - ) : timerStatus === "RUNNING" ? ( + ) : isRunning ? ( ) : isPlayHighlighted ? ( diff --git a/apps/timo-web/containers/todo-modal/detail/DetailTodoModalContainer.tsx b/apps/timo-web/containers/todo-modal/detail/DetailTodoModalContainer.tsx index a2028a9d..15f201e5 100644 --- a/apps/timo-web/containers/todo-modal/detail/DetailTodoModalContainer.tsx +++ b/apps/timo-web/containers/todo-modal/detail/DetailTodoModalContainer.tsx @@ -1,9 +1,12 @@ "use client"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { overlay } from "overlay-kit"; +import { useCallback, useEffect, useState } from "react"; -import type { TodoUpdateRequest } from "@/api/generated/models"; +import type { ErrorType } from "@/api/client/custom-instance"; +import type { ErrorDto, TodoUpdateRequest } from "@/api/generated/models"; +import type { UpdateTodoSubmitHandlers } from "@/hooks/todo-modal/detail/use-update-todo-submit"; import type { ReactNode } from "react"; import { useGetTodoDetail } from "@/api/generated/endpoints/todo/todo"; @@ -22,50 +25,59 @@ export interface DetailTodoModalContainerProps { children: (openDetailTodoModal: () => void) => ReactNode; } -export const DetailTodoModalContainer = ({ +interface DetailTodoModalQueryProps extends Omit< + DetailTodoModalContainerProps, + "children" +> { + isOpen: boolean; + onClose: () => void; + onExited: () => void; + onActionError: (error: ErrorType) => void; +} + +const DetailTodoModalQuery = ({ todoId, date, + isOpen, + onClose, + onExited, onTogglePlay, onToggleCompleted, onDelete, - children, -}: DetailTodoModalContainerProps) => { - const tToast = useTranslations("Toast"); - const [isOpen, setIsOpen] = useState(false); - const [isMounted, setIsMounted] = useState(false); - const [updateErrorMessage, setUpdateErrorMessage] = useState( - null, - ); - const { data, isError } = useGetTodoDetail( - todoId, - { date }, - { query: { enabled: isMounted } }, - ); + onActionError, +}: DetailTodoModalQueryProps) => { + const { data, error, isError } = useGetTodoDetail(todoId, { date }); const { handleDelete } = useDeleteTodoSubmit(); const { handleUpdate } = useUpdateTodoSubmit(); const { data: activeTimer } = useActiveTimer(); const todo = data?.data; const isPlayHighlighted = !activeTimer || activeTimer.todoId === todoId; - const openDetailTodoModal = () => { - setIsMounted(true); - setIsOpen(true); - }; + useEffect(() => { + if (isError && error) { + onActionError(error); + } + }, [error, isError, onActionError]); - const closeDetailTodoModal = () => { - setIsOpen(false); - }; + if (isError || !todo) return null; + + const timerStatus = + activeTimer?.todoId === todoId ? activeTimer.status : todo.timerStatus; const deleteTodo = () => { handleDelete(todoId, { onSuccess: () => { onDelete(); - closeDetailTodoModal(); + onClose(); }, + onError: onActionError, }); }; - const updateTodo = (updateData: TodoUpdateRequest) => { + const updateTodo = ( + updateData: TodoUpdateRequest, + handlers: UpdateTodoSubmitHandlers = {}, + ) => { handleUpdate( { todoId, @@ -73,37 +85,77 @@ export const DetailTodoModalContainer = ({ data: updateData, }, { + onSuccess: handlers.onSuccess, onError: (error) => { - setUpdateErrorMessage( - error.response?.data.message ?? tToast("todoUpdateFailed"), - ); + handlers.onError?.(error); + onActionError(error); }, }, ); }; + return ( + + ); +}; + +export const DetailTodoModalContainer = ({ + todoId, + date, + onTogglePlay, + onToggleCompleted, + onDelete, + children, +}: DetailTodoModalContainerProps) => { + const tToast = useTranslations("Toast"); + const [actionErrorMessage, setActionErrorMessage] = useState(""); + const [isActionErrorToastOpen, setIsActionErrorToastOpen] = useState(false); + + const showActionErrorToast = useCallback( + (error: ErrorType) => { + setActionErrorMessage( + error.response?.data.message ?? tToast("focusActionFailed"), + ); + setIsActionErrorToastOpen(true); + }, + [tToast], + ); + + const openDetailTodoModal = () => { + overlay.open(({ isOpen, close, unmount }) => ( + + )); + }; + return ( <> {children(openDetailTodoModal)} - {isMounted && !isError && todo ? ( - setIsMounted(false)} - todo={todo} - isPlayHighlighted={isPlayHighlighted} - onTogglePlay={onTogglePlay} - onToggleCompleted={onToggleCompleted} - onDelete={deleteTodo} - onUpdate={updateTodo} - /> - ) : null} - setUpdateErrorMessage(null)} - message={updateErrorMessage ?? ""} + isOpen={isActionErrorToastOpen} + onClose={() => setIsActionErrorToastOpen(false)} + message={actionErrorMessage} /> ); diff --git a/apps/timo-web/hooks/todo-modal/common/use-tag-field.tsx b/apps/timo-web/hooks/todo-modal/common/use-tag-field.tsx index c8cfd5b8..d3decd9b 100644 --- a/apps/timo-web/hooks/todo-modal/common/use-tag-field.tsx +++ b/apps/timo-web/hooks/todo-modal/common/use-tag-field.tsx @@ -29,6 +29,7 @@ export interface UseTagFieldResult { isCreateTagErrorToastOpen: boolean; closeCreateTagErrorToast: () => void; handleSelectTag: (label: string) => void; + handleSelectTagById: (tagId: number) => void; handleAddTagClick: () => void; } @@ -66,6 +67,10 @@ export const useTagField = ({ field.onChange(option.id); }; + const handleSelectTagById = (tagId: number) => { + field.onChange(tagId); + }; + const handleAddTagClick = () => { if (tagOptions.length >= MAX_TAG_COUNT) { setIsTagLimitToastOpen(true); @@ -114,6 +119,7 @@ export const useTagField = ({ isCreateTagErrorToastOpen, closeCreateTagErrorToast: () => setIsCreateTagErrorToastOpen(false), handleSelectTag, + handleSelectTagById, handleAddTagClick, }; }; diff --git a/apps/timo-web/hooks/todo-modal/detail/use-delete-todo-submit.ts b/apps/timo-web/hooks/todo-modal/detail/use-delete-todo-submit.ts index 626a7a6a..0ad7d7da 100644 --- a/apps/timo-web/hooks/todo-modal/detail/use-delete-todo-submit.ts +++ b/apps/timo-web/hooks/todo-modal/detail/use-delete-todo-submit.ts @@ -2,6 +2,9 @@ import { useQueryClient } from "@tanstack/react-query"; +import type { ErrorType } from "@/api/client/custom-instance"; +import type { ErrorDto } from "@/api/generated/models"; + import { getGetHomeQueryKey, getGetTodayQueryKey, @@ -10,7 +13,7 @@ import { useDeleteTodo } from "@/api/generated/endpoints/todo/todo"; export interface DeleteTodoSubmitHandlers { onSuccess: () => void; - onError?: () => void; + onError?: (error: ErrorType) => void; } export const useDeleteTodoSubmit = () => { diff --git a/apps/timo-web/hooks/todo-modal/detail/use-detail-todo-form.ts b/apps/timo-web/hooks/todo-modal/detail/use-detail-todo-form.ts index 72c533c9..f50e2a3d 100644 --- a/apps/timo-web/hooks/todo-modal/detail/use-detail-todo-form.ts +++ b/apps/timo-web/hooks/todo-modal/detail/use-detail-todo-form.ts @@ -1,11 +1,7 @@ import { TODO_ICON_VALUES } from "@repo/timo-design-system/ui"; import { useController, useForm } from "react-hook-form"; -import type { - TodoDetailResponse, - TodoUpdateRequest, -} from "@/api/generated/models"; -import type { BuildDetailTodoUpdateRequestParams } from "@/utils/todo/detail-todo-update-request"; +import type { TodoDetailResponse } from "@/api/generated/models"; import type { PriorityLevel, RepeatFrequency, @@ -18,15 +14,11 @@ import { SECONDS_PER_MINUTE } from "@/constants/time"; import { useTagField } from "@/hooks/todo-modal/common/use-tag-field"; import { useDetailSubtaskField } from "@/hooks/todo-modal/detail/use-detail-subtask-field"; import { parseDateKey } from "@/utils/date/date"; -import { buildDetailTodoUpdateRequest } from "@/utils/todo/detail-todo-update-request"; import { TITLE_MAX_WEIGHTED_LENGTH, truncateToWeightedLength, } from "@/utils/todo/text-length"; -import { - convertDurationToTimeText, - convertSecondsToApiDuration, -} from "@/utils/todo/todo-time"; +import { convertSecondsToApiDuration } from "@/utils/todo/todo-time"; interface DetailTodoFormValues { date: Date; @@ -80,11 +72,8 @@ export interface UseDetailTodoFormParams { todo: TodoDetailResponse; } -export type DetailTodoUpdateRequestOverrides = - Partial; - export const useDetailTodoForm = ({ todo }: UseDetailTodoFormParams) => { - const durationText = convertDurationToTimeText(todo.durationSeconds ?? 0); + const durationText = convertSecondsToApiDuration(todo.durationSeconds ?? 0); const todoIcon = todo.icon ?? null; const todoDate = parseDateKey(todo.date) ?? new Date(); const repeatType = isRepeatFrequency(todo.repeat.type) @@ -138,6 +127,11 @@ export const useDetailTodoForm = ({ todo }: UseDetailTodoFormParams) => { const selectIcon = (nextIcon: TodoIconValue) => iconField.onChange(nextIcon); const removeIcon = () => iconField.onChange(null); + const setTagId = (tagId: number) => tagField.handleSelectTagById(tagId); + const setSelectedWeekdayIds = (weekdayIds: string[]) => + selectedWeekdayIdsField.onChange(weekdayIds); + const setSubtaskCompleted = (id: number, completed: boolean) => + subtaskField.handleCompletedChange(id, completed); const changeTitle = (value: string) => { titleField.onChange( @@ -154,12 +148,7 @@ export const useDetailTodoForm = ({ todo }: UseDetailTodoFormParams) => { if (!option) return undefined; - const time = convertSecondsToApiDuration( - option.minute * SECONDS_PER_MINUTE, - ); - timeField.onChange(time); - - return time; + return convertSecondsToApiDuration(option.minute * SECONDS_PER_MINUTE); }; const changeRepeatFrequency = (frequency: RepeatFrequency) => { @@ -168,54 +157,22 @@ export const useDetailTodoForm = ({ todo }: UseDetailTodoFormParams) => { }; const toggleWeekday = (weekdayId: string) => { - const nextWeekdayIds = selectedWeekdayIdsField.value.includes(weekdayId) + return selectedWeekdayIdsField.value.includes(weekdayId) ? selectedWeekdayIdsField.value.filter((item) => item !== weekdayId) : [...selectedWeekdayIdsField.value, weekdayId]; - - selectedWeekdayIdsField.onChange(nextWeekdayIds); - - return nextWeekdayIds; }; - const handleSelectTag = (label: string) => { + const getTagIdByLabel = (label: string) => { const option = tagField.tagOptions.find((item) => item.label === label); if (!option) return null; - tagField.handleSelectTag(label); - return option.id; }; const changeSubtaskCompleted = (id: number, completed: boolean) => { - const nextSubtasks = subtaskField.subtaskInputs.map((subtask) => + return subtaskField.subtaskInputs.map((subtask) => subtask.id === id ? { ...subtask, completed } : subtask, ); - - subtaskField.handleCompletedChange(id, completed); - - return nextSubtasks; - }; - - const buildUpdateRequest = ( - overrides: DetailTodoUpdateRequestOverrides = {}, - ): TodoUpdateRequest => { - const params: BuildDetailTodoUpdateRequestParams = { - icon: iconField.value, - title: titleField.value, - date: dateField.value, - time: timeField.value, - priority: priorityField.value, - tagId: tagField.selectedTagId, - isRepeatActive: isRepeatActiveField.value, - repeatFrequency: repeatFrequencyField.value, - selectedWeekdayIds: selectedWeekdayIdsField.value, - repeatDay: repeatDayField.value, - memo: memoField.value, - subtasks: subtaskField.subtaskInputs, - ...overrides, - }; - - return buildDetailTodoUpdateRequest(params); }; return { @@ -227,10 +184,12 @@ export const useDetailTodoForm = ({ todo }: UseDetailTodoFormParams) => { setPriority: priorityField.onChange, tagLabels: tagField.tagLabels, selectedTagLabel: tagField.selectedTagLabel, - handleSelectTag, + getTagIdByLabel, + setTagId, isRepeatActive: isRepeatActiveField.value, repeatFrequency: repeatFrequencyField.value, selectedWeekdayIds: selectedWeekdayIdsField.value, + setSelectedWeekdayIds, repeatDay: repeatDayField.value, setRepeatDay: repeatDayField.onChange, title: titleField.value, @@ -239,6 +198,7 @@ export const useDetailTodoForm = ({ todo }: UseDetailTodoFormParams) => { registerSubtaskInputRef: subtaskField.registerInputRef, changeSubtaskInput: subtaskField.handleInputChange, changeSubtaskCompleted, + setSubtaskCompleted, handleSubtaskInputKeyDown: subtaskField.handleInputKeyDown, memo: memoField.value, setMemo: memoField.onChange, @@ -248,8 +208,9 @@ export const useDetailTodoForm = ({ todo }: UseDetailTodoFormParams) => { selectTime, changeRepeatFrequency, toggleWeekday, - buildUpdateRequest, handleSubmit, dirtyFields: formState.dirtyFields, }; }; + +export type UseDetailTodoFormReturn = ReturnType; diff --git a/apps/timo-web/hooks/todo-modal/detail/use-detail-todo-icon-submit.ts b/apps/timo-web/hooks/todo-modal/detail/use-detail-todo-icon-submit.ts new file mode 100644 index 00000000..d3e9b482 --- /dev/null +++ b/apps/timo-web/hooks/todo-modal/detail/use-detail-todo-icon-submit.ts @@ -0,0 +1,71 @@ +import { useState } from "react"; + +import type { TodoUpdateRequest } from "@/api/generated/models"; +import type { UpdateTodoSubmitHandlers } from "@/hooks/todo-modal/detail/use-update-todo-submit"; +import type { TodoIconValue } from "@repo/timo-design-system/ui"; + +export interface UseDetailTodoIconSubmitParams { + icon: TodoIconValue | null; + selectIcon: (icon: TodoIconValue) => void; + onUpdate: ( + data: TodoUpdateRequest, + handlers?: UpdateTodoSubmitHandlers, + ) => void; +} + +export const useDetailTodoIconSubmit = ({ + icon, + selectIcon, + onUpdate, +}: UseDetailTodoIconSubmitParams) => { + const [isIconPanelOpen, setIsIconPanelOpen] = useState(false); + const [pendingIcon, setPendingIcon] = useState(icon); + + const handleSelectIcon = (nextIcon: TodoIconValue) => { + setPendingIcon(nextIcon); + }; + + const handleOpenIconPanel = () => { + setPendingIcon(icon); + setIsIconPanelOpen(true); + }; + + const handleSubmitIcon = () => { + if (!pendingIcon || pendingIcon === icon) { + setIsIconPanelOpen(false); + return; + } + + onUpdate( + { icon: pendingIcon }, + { + onSuccess: () => { + selectIcon(pendingIcon); + setIsIconPanelOpen(false); + }, + }, + ); + }; + + const handleToggleIconPanel = () => { + if (isIconPanelOpen) { + handleSubmitIcon(); + return; + } + + handleOpenIconPanel(); + }; + + const handleRemoveIcon = () => { + setPendingIcon(null); + }; + + return { + icon: isIconPanelOpen ? pendingIcon : icon, + isIconPanelOpen, + handleOpenIconPanel, + handleToggleIconPanel, + handleSelectIcon, + handleRemoveIcon, + }; +}; diff --git a/apps/timo-web/hooks/todo-modal/detail/use-detail-todo-patch-handlers.ts b/apps/timo-web/hooks/todo-modal/detail/use-detail-todo-patch-handlers.ts new file mode 100644 index 00000000..66e8f133 --- /dev/null +++ b/apps/timo-web/hooks/todo-modal/detail/use-detail-todo-patch-handlers.ts @@ -0,0 +1,149 @@ +import { useState } from "react"; + +import type { TodoUpdateRequest } from "@/api/generated/models"; +import type { UseDetailTodoFormReturn } from "@/hooks/todo-modal/detail/use-detail-todo-form"; +import type { UpdateTodoSubmitHandlers } from "@/hooks/todo-modal/detail/use-update-todo-submit"; +import type { + PriorityLevel, + RepeatFrequency, + TimeSelection, +} from "@repo/timo-design-system/ui"; + +import { formatDateKey } from "@/utils/date/date"; +import { + buildDetailTodoSubtasksUpdateRequest, + isTodoUpdateRepeatWeekday, +} from "@/utils/todo/detail-todo-update-request"; +import { + convertApiDurationToSeconds, + convertClockTimeTextToApiDuration, +} from "@/utils/todo/todo-time"; + +export interface UseDetailTodoPatchHandlersParams { + form: UseDetailTodoFormReturn; + onUpdate: ( + data: TodoUpdateRequest, + handlers?: UpdateTodoSubmitHandlers, + ) => void; +} + +export const useDetailTodoPatchHandlers = ({ + form, + onUpdate, +}: UseDetailTodoPatchHandlersParams) => { + const [selectedTime, setSelectedTime] = useState(); + + const updateTodo = ( + updateData: TodoUpdateRequest, + handlers?: UpdateTodoSubmitHandlers, + ) => { + if ("title" in updateData && !updateData.title?.trim()) return; + onUpdate(updateData, handlers); + }; + + const handleSelectTime = (nextTime: TimeSelection) => { + const time = form.selectTime(nextTime); + + const durationSeconds = time ? convertApiDurationToSeconds(time) : 0; + + if (durationSeconds) { + updateTodo( + { durationSeconds }, + { + onSuccess: () => { + setSelectedTime(nextTime); + form.setTime(time); + }, + }, + ); + } + }; + + const handleDateChange = (nextDate: Date) => { + updateTodo( + { date: formatDateKey(nextDate) }, + { onSuccess: () => form.setDate(nextDate) }, + ); + }; + + const handleTimeChange = (time: string) => { + const apiDuration = convertClockTimeTextToApiDuration(time); + const durationSeconds = convertApiDurationToSeconds(apiDuration); + + if (durationSeconds) { + updateTodo( + { durationSeconds }, + { onSuccess: () => form.setTime(apiDuration) }, + ); + } + }; + + const handleSelectPriority = (priority: PriorityLevel) => { + updateTodo({ priority }, { onSuccess: () => form.setPriority(priority) }); + }; + + const handleSelectTag = (label: string) => { + const tagId = form.getTagIdByLabel(label); + + if (tagId !== null) { + updateTodo({ tagId }, { onSuccess: () => form.setTagId(tagId) }); + } + }; + + const handleRepeatFrequencyChange = (repeatFrequency: RepeatFrequency) => { + updateTodo( + { repeatType: repeatFrequency }, + { onSuccess: () => form.changeRepeatFrequency(repeatFrequency) }, + ); + }; + + const handleWeekdayToggle = (weekdayId: string) => { + const selectedWeekdayIds = form.toggleWeekday(weekdayId); + updateTodo( + { + repeatType: "WEEKLY", + repeatWeekdays: selectedWeekdayIds.filter(isTodoUpdateRepeatWeekday), + }, + { + onSuccess: () => form.setSelectedWeekdayIds(selectedWeekdayIds), + }, + ); + }; + + const handleRepeatDayChange = (repeatDay: string) => { + const repeatDayOfMonth = Number(repeatDay); + + if ( + Number.isInteger(repeatDayOfMonth) && + repeatDayOfMonth >= 1 && + repeatDayOfMonth <= 31 + ) { + updateTodo( + { repeatType: "MONTHLY", repeatDayOfMonth }, + { onSuccess: () => form.setRepeatDay(repeatDay) }, + ); + } + }; + + const handleSubtaskCompletedChange = (id: number, completed: boolean) => { + const subtasks = form.changeSubtaskCompleted(id, completed); + updateTodo( + { subtasks: buildDetailTodoSubtasksUpdateRequest(subtasks) }, + { onSuccess: () => form.setSubtaskCompleted(id, completed) }, + ); + }; + + return { + selectedTime, + updateTodo, + handleSelectTime, + handleDateChange, + handleTimeChange, + handleSelectPriority, + handleSelectTag, + handleRepeatFrequencyChange, + handleWeekdayToggle, + handleRepeatDayChange, + handleSubtaskCompletedChange, + }; +}; diff --git a/apps/timo-web/hooks/todo-modal/detail/use-detail-todo-text-auto-save.ts b/apps/timo-web/hooks/todo-modal/detail/use-detail-todo-text-auto-save.ts new file mode 100644 index 00000000..0ab5bd09 --- /dev/null +++ b/apps/timo-web/hooks/todo-modal/detail/use-detail-todo-text-auto-save.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import type { TodoUpdateRequest } from "@/api/generated/models"; +import type { DetailTodoSubtaskInput } from "@/hooks/todo-modal/detail/use-detail-subtask-field"; +import type { UpdateTodoSubmitHandlers } from "@/hooks/todo-modal/detail/use-update-todo-submit"; + +import { buildDetailTodoTextUpdateRequest } from "@/utils/todo/detail-todo-update-request"; + +const TEXT_UPDATE_DEBOUNCE_MS = 2000; + +export interface UseDetailTodoTextAutoSaveParams { + isOpen: boolean; + title: string; + memo: string; + subtasks: DetailTodoSubtaskInput[]; + onUpdate: ( + data: TodoUpdateRequest, + handlers?: UpdateTodoSubmitHandlers, + ) => void; +} + +export const useDetailTodoTextAutoSave = ({ + isOpen, + title, + memo, + subtasks, + onUpdate, +}: UseDetailTodoTextAutoSaveParams) => { + const latestOnUpdateRef = useRef(onUpdate); + const didStartTextUpdateRef = useRef(false); + const textUpdateSignature = useMemo( + () => + JSON.stringify({ + title, + memo, + subtasks: subtasks.map((subtask) => ({ + id: subtask.id, + subtaskId: subtask.subtaskId, + value: subtask.value, + })), + }), + [memo, subtasks, title], + ); + const lastSubmittedTextUpdateSignatureRef = useRef(textUpdateSignature); + + const buildTextUpdateRequest = useCallback( + (): TodoUpdateRequest => + buildDetailTodoTextUpdateRequest({ + title, + memo, + subtasks, + }), + [memo, subtasks, title], + ); + const latestBuildTextUpdateRequestRef = useRef(buildTextUpdateRequest); + + const submitTextUpdate = useCallback(() => { + if (!title.trim()) return; + if (lastSubmittedTextUpdateSignatureRef.current === textUpdateSignature) { + return; + } + + latestOnUpdateRef.current(latestBuildTextUpdateRequestRef.current(), { + onSuccess: () => { + lastSubmittedTextUpdateSignatureRef.current = textUpdateSignature; + }, + }); + }, [textUpdateSignature, title]); + + useEffect(() => { + latestOnUpdateRef.current = onUpdate; + }, [onUpdate]); + + useEffect(() => { + latestBuildTextUpdateRequestRef.current = buildTextUpdateRequest; + }, [buildTextUpdateRequest]); + + useEffect(() => { + if (!isOpen) return; + + if (!didStartTextUpdateRef.current) { + didStartTextUpdateRef.current = true; + return; + } + + const updateTimer = window.setTimeout( + submitTextUpdate, + TEXT_UPDATE_DEBOUNCE_MS, + ); + + return () => window.clearTimeout(updateTimer); + }, [isOpen, submitTextUpdate]); + + return { + submitTextUpdate, + }; +}; diff --git a/apps/timo-web/utils/todo/detail-todo-update-request.ts b/apps/timo-web/utils/todo/detail-todo-update-request.ts index bf669e5e..0b1de024 100644 --- a/apps/timo-web/utils/todo/detail-todo-update-request.ts +++ b/apps/timo-web/utils/todo/detail-todo-update-request.ts @@ -1,18 +1,11 @@ import type { + TodoSubtaskUpdateRequest, TodoUpdateRequest, TodoUpdateRequestRepeatWeekdaysItem, } from "@/api/generated/models"; import type { DetailTodoSubtaskInput } from "@/hooks/todo-modal/detail/use-detail-subtask-field"; -import type { - PriorityLevel, - RepeatFrequency, - TodoIconValue, -} from "@repo/timo-design-system/ui"; - -import { formatDateKey } from "@/utils/date/date"; -import { convertTimeTextToDurationSeconds } from "@/utils/todo/todo-time"; -const UPDATE_REPEAT_WEEKDAYS = [ +const DETAIL_TODO_UPDATE_WEEKDAYS = [ "MON", "TUE", "WED", @@ -22,72 +15,34 @@ const UPDATE_REPEAT_WEEKDAYS = [ "SUN", ] as const; -const isUpdateRepeatWeekday = ( +export const isTodoUpdateRepeatWeekday = ( weekdayId: string, ): weekdayId is TodoUpdateRequestRepeatWeekdaysItem => - (UPDATE_REPEAT_WEEKDAYS as readonly string[]).includes(weekdayId); + (DETAIL_TODO_UPDATE_WEEKDAYS as readonly string[]).includes(weekdayId); + +export const buildDetailTodoSubtasksUpdateRequest = ( + subtasks: DetailTodoSubtaskInput[], +): TodoSubtaskUpdateRequest[] => + subtasks + .map((subtask) => ({ + subtaskId: subtask.subtaskId ?? undefined, + content: subtask.value.trim(), + completed: subtask.completed, + })) + .filter((subtask) => subtask.content.length > 0); -export interface BuildDetailTodoUpdateRequestParams { - icon: TodoIconValue | null; +export interface BuildDetailTodoTextUpdateRequestParams { title: string; - date: Date; - time: string; - priority: PriorityLevel; - tagId: number | null; - isRepeatActive: boolean; - repeatFrequency: RepeatFrequency; - selectedWeekdayIds: string[]; - repeatDay: string; memo: string; subtasks: DetailTodoSubtaskInput[]; } -export const buildDetailTodoUpdateRequest = ({ - icon, +export const buildDetailTodoTextUpdateRequest = ({ title, - date, - time, - priority, - tagId, - isRepeatActive, - repeatFrequency, - selectedWeekdayIds, - repeatDay, memo, subtasks, -}: BuildDetailTodoUpdateRequestParams): TodoUpdateRequest => { - const durationSeconds = convertTimeTextToDurationSeconds(time); - const repeatType = isRepeatActive ? repeatFrequency : "NONE"; - const repeatDayOfMonth = Number(repeatDay); - const repeatWeekdays = selectedWeekdayIds.filter(isUpdateRepeatWeekday); - const updateSubtasks = subtasks - .map((subtask) => ({ - subtaskId: subtask.subtaskId ?? undefined, - content: subtask.value.trim(), - completed: subtask.completed, - })) - .filter((subtask) => subtask.content.length > 0); - - return { - icon: icon ?? undefined, - title: title.trim(), - date: formatDateKey(date), - durationSeconds: durationSeconds > 0 ? durationSeconds : undefined, - priority, - tagId: tagId ?? undefined, - repeatType, - repeatWeekdays: - repeatType === "WEEKLY" && repeatWeekdays.length > 0 - ? repeatWeekdays - : undefined, - repeatDayOfMonth: - repeatType === "MONTHLY" && - Number.isInteger(repeatDayOfMonth) && - repeatDayOfMonth >= 1 && - repeatDayOfMonth <= 31 - ? repeatDayOfMonth - : undefined, - memo: memo.trim(), - subtasks: updateSubtasks, - }; -}; +}: BuildDetailTodoTextUpdateRequestParams): TodoUpdateRequest => ({ + title: title.trim(), + memo: memo.trim(), + subtasks: buildDetailTodoSubtasksUpdateRequest(subtasks), +}); diff --git a/apps/timo-web/utils/todo/todo-time.ts b/apps/timo-web/utils/todo/todo-time.ts index 21b30146..93aaa78b 100644 --- a/apps/timo-web/utils/todo/todo-time.ts +++ b/apps/timo-web/utils/todo/todo-time.ts @@ -29,6 +29,26 @@ export const convertApiDurationToSeconds = (duration: string): number => { return minutes * SECONDS_PER_MINUTE + seconds; }; +export const convertApiDurationToClockTimeText = (duration: string): string => { + const totalSeconds = convertApiDurationToSeconds(duration); + const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR); + const minutes = Math.floor( + (totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE, + ); + + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; +}; + +export const convertClockTimeTextToApiDuration = (timeText: string): string => { + const [hoursText = "0", minutesText = "0"] = timeText.split(":"); + const hours = Number(hoursText) || 0; + const minutes = Number(minutesText) || 0; + + return convertSecondsToApiDuration( + hours * SECONDS_PER_HOUR + minutes * SECONDS_PER_MINUTE, + ); +}; + export const convertTimeTextToDurationSeconds = (timeText: string): number => { const normalized = timeText.trim().toLowerCase(); const hourMatch = /^(\d+(?:\.\d+)?)\s*h$/.exec(normalized);