Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
11e3e0b
refactor(web): 상세 투두 모달을 overlay 기반 API 컨테이너로 분리 (#202)
yumin-kim2 Jul 14, 2026
fef23fd
refactor(web): 상세 투두 수정 요청을 변경 필드 단위로 전송 (#202)
yumin-kim2 Jul 14, 2026
73e92b7
fix(web): 상세 투두 텍스트 수정 후 모달 닫을 때 변경사항을 반영 (#202)
yumin-kim2 Jul 14, 2026
3de47bc
fix(web): 홈 투두 카드 플레이 버튼이 드래그 이벤트에 막히지 않도록 수정 (#202)
yumin-kim2 Jul 15, 2026
b0c0e9a
fix(web): 상세 투두 아이콘 선택 후 확정 시에만 수정 요청 전송 (#202)
yumin-kim2 Jul 15, 2026
6b133bc
fix(web): 상세 투두 모달에 활성 타이머 상태 반영 (#202)
yumin-kim2 Jul 15, 2026
8d7cd00
fix(web): 투두 플레이 버튼 클릭 이벤트 전파 방지 (#202)
yumin-kim2 Jul 15, 2026
0cb5616
Merge remote-tracking branch 'origin/develop' into fix/web/202-todo-d…
yumin-kim2 Jul 15, 2026
3338080
fix(web): 상세 투두 조회/수정/삭제 실패 토스트 처리 추가 (#202)
yumin-kim2 Jul 15, 2026
b7694bc
refactor(web): 상세 투두 모달 수정 로직을 훅으로 분리 (#202)
yumin-kim2 Jul 15, 2026
541d6b9
fix(web): 상세 투두 시간 표시와 전송 형식 분리 (#202)
yumin-kim2 Jul 15, 2026
bde7dcd
Merge remote-tracking branch 'origin/develop' into fix/web/202-todo-d…
yumin-kim2 Jul 15, 2026
80d5d93
refactor(web): 투두 카드 이벤트 처리 정리 (#202)
yumin-kim2 Jul 15, 2026
43e27ad
fix(web): 상세 투두 수정 성공 후 폼 상태 반영 (#202)
yumin-kim2 Jul 15, 2026
144d973
Merge remote-tracking branch 'origin/develop' into fix/web/202-todo-d…
yumin-kim2 Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export const HomeTodoCard = ({
};

const stopInteractiveEvent = (
event: MouseEvent<HTMLButtonElement> | PointerEvent<HTMLButtonElement>,
event: MouseEvent<HTMLElement> | PointerEvent<HTMLElement>,
) => {
event.stopPropagation();
};
Expand All @@ -129,24 +129,29 @@ export const HomeTodoCard = ({
{title}
</p>
</div>
<PlayButton
variant={isRunning ? "stop" : "play"}
size="sm"
disabled={isCompleted}
active={isPlayHighlighted}
onClick={handlePlayClick}
<div
role="none"
onPointerDown={stopInteractiveEvent}
onClick={stopInteractiveEvent}
>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{isCompleted ? (
<PlayDisabledIcon />
) : isRunning ? (
<StopIcon />
) : isPlayHighlighted ? (
<PlayIcon />
) : (
<PlayDisabledIcon />
)}
</PlayButton>
<PlayButton
variant={isRunning ? "stop" : "play"}
size="sm"
disabled={isCompleted}
active={isPlayHighlighted}
onClick={handlePlayClick}
>
{isCompleted ? (
<PlayDisabledIcon />
) : isRunning ? (
<StopIcon />
) : isPlayHighlighted ? (
<PlayIcon />
) : (
<PlayDisabledIcon />
)}
</PlayButton>
</div>
</div>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,11 @@ export const TodayTodoCard = ({
size="lg"
disabled={isDone}
active={isPlayHighlighted}
onClick={onPlay}
onClick={(event) => {
stopPropagation(event);
onPlay();
}}
onPointerDown={stopPropagation}
>
{isDone ? (
<PlayDisabledIcon width={24} height={24} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ export const SettingsLogoutModalContainer = ({
</Modal.Icon>
<Modal.Title>
{tSettings.rich("profile.logoutConfirmTitle", {
red: (chunks) => (
<span className="text-timo-red">{chunks}</span>
),
red: (chunks) => <span className="text-timo-red">{chunks}</span>,
})}
</Modal.Title>
<Modal.Description className="whitespace-pre-line">
Expand Down
205 changes: 61 additions & 144 deletions apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 = (
Expand All @@ -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 = ({
Expand All @@ -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<TimeSelection>();
const [isIconPanelOpen, setIsIconPanelOpen] = useState(false);
const canUpdateTodo = todo.timerStatus === "STOPPED";
const dateNumber = detailTodoForm.date.getDate();
const dayOfWeek = isDetailTodoWeekdayId(todo.dayOfWeek)
? todo.dayOfWeek
Expand All @@ -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 (
<OverlayModal
isOpen={isOpen}
onClose={onClose}
onClose={handleClose}
onExited={onExited}
ariaLabel={t("ariaLabel")}
className="w-124 items-start px-7.5 py-5"
>
<div className="flex w-full justify-end">
<button type="button" aria-label={tCommon("close")} onClick={onClose}>
<button
type="button"
aria-label={tCommon("close")}
onClick={handleClose}
>
<DeleteIcon />
</button>
</div>
Expand All @@ -210,13 +125,13 @@ export const DetailTodoModalContent = ({

<div className={canUpdateTodo ? undefined : "pointer-events-none"}>
<TodoIconField
icon={detailTodoForm.icon}
isIconPanelOpen={isIconPanelOpen}
icon={iconField.icon}
isIconPanelOpen={iconField.isIconPanelOpen}
addIconLabel={tCreateModal("addIcon")}
onOpenPanel={() => setIsIconPanelOpen(true)}
onTogglePanel={() => setIsIconPanelOpen((prev) => !prev)}
onSelectIcon={handleSelectIcon}
onRemoveIcon={handleRemoveIcon}
onOpenPanel={iconField.handleOpenIconPanel}
onTogglePanel={iconField.handleToggleIconPanel}
onSelectIcon={iconField.handleSelectIcon}
onRemoveIcon={iconField.handleRemoveIcon}
/>
</div>
</div>
Expand All @@ -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}
/>
Expand All @@ -252,28 +169,28 @@ export const DetailTodoModalContent = ({
<TodoToolbar
dateLabel={formatShortDateLabel(detailTodoForm.date)}
date={detailTodoForm.date}
onDateChange={handleDateChange}
timeLabel={detailTodoForm.time}
onDateChange={patchHandlers.handleDateChange}
timeLabel={displayTime}
timeOptions={DETAIL_TODO_TIME_OPTIONS}
time={detailTodoForm.time}
onTimeChange={handleTimeChange}
selectedTime={selectedTime}
onSelectTime={handleSelectTime}
time={displayTime}
onTimeChange={patchHandlers.handleTimeChange}
selectedTime={patchHandlers.selectedTime}
onSelectTime={patchHandlers.handleSelectTime}
priority={detailTodoForm.priority}
priorityLabels={{
VERY_HIGH: tCommon("priority.urgent"),
HIGH: tCommon("priority.high"),
MEDIUM: tCommon("priority.medium"),
LOW: tCommon("priority.low"),
}}
onSelectPriority={handleSelectPriority}
onSelectPriority={patchHandlers.handleSelectPriority}
tagLabel={
detailTodoForm.selectedTagLabel ?? tCreateModal("tagLabel")
}
tags={detailTodoForm.tagLabels}
selectedTag={detailTodoForm.selectedTagLabel}
addTagLabel={tCreateModal("addTag")}
onSelectTag={handleSelectTag}
onSelectTag={patchHandlers.handleSelectTag}
onAddTagClick={() => {}}
hasMemo={Boolean(todo.memo?.trim())}
isRepeatActive={detailTodoForm.isRepeatActive}
Expand All @@ -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,
},
}}
/>
Expand Down
Loading
Loading