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);