[FIX] 투두 생성/조회 모달 및 카드 UX 개선·버그 수정 - #240
Conversation
- 태그/시간/우선순위/반복 드롭다운이 항목 클릭 즉시 닫히지 않고, 실제로 닫힐 때만 선택이 반영되도록 draft 상태와 closeOnSelect={false}를 적용했습니다
- 반복 셀렉터 화살표(chevron) 회전 방향이 반대로 되어 있던 것을 수정했습니다
- 시간 드롭다운이 열려 있는 상태에서 AI 추천 응답이 도착해도 draft 상태가 갱신되지 않아 선택 표시가 안 되던 것을 수정했습니다
- RepeatSelector의 onWeekdayToggle(id) prop이 onWeekdaysChange(weekdayIds[])로 바뀜에 따라 소비 측 핸들러를 갱신했습니다
- 날짜 변경 PATCH 성공 후에도 예전 날짜로 상세를 재조회해 404가 발생하던 것을, 성공 시점에 조회 기준 날짜를 함께 갱신하도록 수정했습니다 - 날짜 변경으로 쿼리 키가 바뀌는 동안 데이터가 잠깐 사라져 모달이 깜빡이던 것을 keepPreviousData로 방지했습니다
- 타이틀 입력을 textarea에서 input으로 바꿔 줄바꿈 없이 가로로만 입력되게 하고, Enter 입력 시 첫 하위 태스크로 포커스가 이동하도록 했습니다 - 태그 추가 모달에서 새 태그를 만들면 PATCH 성공 후 로컬 상태에 반영되도록 연동했습니다 - 하위 태스크 완료 토글을 기존의 전체 하위 태스크 배열 PATCH 방식 대신 전용 상태 변경 API로 호출하도록 변경했습니다 - 하위 태스크 입력의 값(value) 타입을 훅이 아닌 프레젠테이션 컴포넌트(DetailTodoTaskFields)가 소유하도록 옮겼습니다
- 타이틀 입력을 textarea에서 input으로 바꿔 줄바꿈 없이 가로로만 입력되게 했습니다 - Enter 입력 시 개행 대신 첫 번째 하위 태스크 입력창으로 포커스가 이동하도록 했습니다
- 아이콘 클릭 시 패널을 접었다 펴야 반영되던 것을, 클릭 즉시 서버에 반영되고 패널이 자동으로 닫히도록 변경했습니다 - 아이콘 제거가 로컬에서만 초기화되고 서버에 반영되지 않던 것을, 스웨거에 정의된 icon: "NONE"을 PATCH로 전송하도록 수정했습니다 (기존 생성된 API 클라이언트에 NONE이 누락되어 있어 enum에 추가했습니다) - 아이콘 패널 열림/닫힘 시 상단 버튼과 패널 애니메이션 타이밍이 어긋나 끊겨 보이던 것을 다듬었습니다
- 생성 모달 시간 트리거의 시(hour) 자리에 2자리 패딩이 빠져 있어 "0:00"으로 보이던 것을 "00:00"으로 수정했습니다
- todoSchema에서 priority가 없을 때 "MEDIUM"으로 강제 대체하던 것을 제거해, 실제로 설정하지 않은 투두는 홈 카드에 우선순위 아이콘이 표시되지 않도록 했습니다 - 홈 투두카드에 hover 상태를 추가해, 완료된 카드가 Today 탭과 동일하게 마우스를 올리면 일시적으로 밝게 보이도록 했습니다 - createTodoRequestSchema에서 반복 [매주] 선택 후 요일을 선택하지 않으면 제출이 막히던 것을, repeatType을 NONE으로 정규화해 반복 없이 생성되도록 수정했습니다
- Common.priority 번역 키를 urgent/high/medium/low에서 VERY_HIGH/HIGH/MEDIUM/LOW로 바꿔, 여러 화면에 흩어져 있던 API enum ↔ 번역 키 매핑 테이블을 없앴습니다
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
Next review available in: 20 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (3)
📒 Files selected for processing (10)
Walkthrough투두 카드의 완료 상태 표시, 생성·조회 모달 입력과 제출 흐름, 디자인 시스템 선택기의 드롭다운 확정 방식, 반복·우선순위 스키마 및 아이콘 패널 애니메이션이 변경되었습니다. Changes투두 UX 및 데이터 흐름
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant DetailTodoModalContent
participant DetailTodoModalContainer
participant useToggleSubtaskSubmit
participant TodoQuery
User->>DetailTodoModalContent: 서브태스크 완료 상태 변경
DetailTodoModalContent->>DetailTodoModalContainer: onToggleSubtask(subtaskId, completed)
DetailTodoModalContainer->>useToggleSubtaskSubmit: handleToggle(todoId, subtaskId, date, completed)
useToggleSubtaskSubmit->>TodoQuery: 상태 변경 요청 및 관련 쿼리 무효화
TodoQuery-->>DetailTodoModalContainer: 갱신된 상세 데이터
DetailTodoModalContainer-->>DetailTodoModalContent: 성공·오류 콜백 전달
Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Timo Performance ReportBundle Size — timo-web
Lighthouse — timo-web
Image Optimization — timo-web
측정 커밋: |
There was a problem hiding this comment.
Actionable comments posted: 12
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@apps/timo-web/app/`[locale]/(main)/(with-time-sidebar)/home/_components/todo-card/HomeTodoCard.tsx:
- Around line 167-173: Update the Todo card container in HomeTodoCard to use a
semantic article element instead of a generic div, and add onFocus/onBlur
handlers that mirror the existing hover state updates. Update handleCardClick
and handleCardKeyDown event types to HTMLElement so the article handlers remain
type-safe.
In `@apps/timo-web/components/todo-modal/common/TodoIconField.tsx`:
- Around line 79-93: Update the exiting panel wrapper around IconSelector so
that when isPanelVisible is false it is hidden from assistive technology and
removed from keyboard interaction using aria-hidden together with inert (or
propagated tabIndex={-1}), while preserving shouldRenderPanel for the exit
animation.
In `@apps/timo-web/components/todo-modal/create/CreateTodoTaskFields.tsx`:
- Around line 48-61: Update the input in CreateTodoTaskFields to include a
unique id and add a corresponding label using htmlFor, using an sr-only class if
the label should remain visually hidden. Preserve the existing title value,
change handling, and Enter/composition-key behavior.
In `@apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx`:
- Around line 147-157: Update the TodoIconField container and the corresponding
edit-control region around the later fields so canUpdateTodo blocks keyboard
focus and activation as well as pointer interaction. Apply inert to the disabled
regions where supported, or pass disabled state to each interactive control and
guard their handlers with canUpdateTodo, while preserving normal editing
behavior when updates are allowed.
- Line 217: Update the hasMemo prop in DetailTodoModalContent to derive from the
local form value rather than todo.memo, trimming and coercing that current value
to a boolean so the memo indicator updates while editing.
In `@apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx`:
- Around line 80-92: Update the title input in DetailTodoTaskFields by assigning
it a stable id and adding a visually hidden label whose htmlFor references that
id; replace the unconditional outline-none styling with an accessible
focus-visible indicator while preserving the existing non-focus styling and
input behavior.
- Around line 17-22: Move the shared DetailTodoSubtaskInput interface out of
DetailTodoTaskFields.tsx into a dedicated common type module, then update
use-detail-todo-text-auto-save.ts and use-detail-subtask-field.ts to import it
from that module. Affected sites:
apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx lines
17-22—remove the local interface and use the shared type;
apps/timo-web/hooks/todo-modal/detail/use-detail-todo-text-auto-save.ts line
4—switch to the shared type import;
apps/timo-web/hooks/todo-modal/detail/use-detail-subtask-field.ts line 3—switch
to the shared type import.
In `@apps/timo-web/hooks/todo-modal/detail/use-detail-todo-icon-submit.ts`:
- Around line 23-55: Serialize icon update requests in handleSelectIcon and
handleRemoveIcon so a new selection or removal cannot start while a PATCH is
pending. Track the in-flight state around onUpdate, clear it in both onSuccess
and onError, and either block additional submissions or retain only the latest
pending choice for processing after completion.
In `@apps/timo-web/hooks/todo-modal/detail/use-detail-todo-patch-handlers.ts`:
- Around line 103-113: Update handleWeekdaysChange so an empty weekdayIds array
is normalized to the no-repeat type ("NONE" or the existing equivalent) instead
of sending repeatType "WEEKLY"; preserve the filtered weekday IDs for non-empty
selections and align the behavior with the creation modal.
In `@packages/timo-design-system/src/components/layout/dropdown/Dropdown.tsx`:
- Around line 62-65: Update the Dropdown component’s setOpenState and
outside-click handling to store the latest onOpenChange callback in a ref, then
have handleOutsideClick invoke the ref’s current callback instead of a stale
closure. Keep the effect stable without relying on the disabled exhaustive-deps
warning, while preserving the existing open-state behavior.
In
`@packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.tsx`:
- Around line 167-230: Extract the repeated draft state/ref synchronization and
open-close commit behavior from RepeatSelector’s handleOpenChange and
corresponding TimeSelector logic into a shared useDraftValue-style hook. Use the
hook for frequency, weekday IDs, and repeat day while preserving initialization
on open, diff-only callbacks on close, and the existing selector-specific
comparison behavior.
In
`@packages/timo-design-system/src/components/time/time-selector/TimeSelector.tsx`:
- Around line 52-108: TimeSelector의 draft state/ref 동기화와 handleOpenChange 커밋 로직이
RepeatSelector와 중복되므로 공용 훅으로 추출하고 두 컴포넌트가 이를 재사용하도록 변경하세요. draftTime,
draftSelected 관리, 열릴 때 원본 값으로 재설정, 닫힐 때 변경된 값 커밋 동작은 기존과 동일하게 유지하세요.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: c1829209-97cb-4dce-ae41-6a099d8205f3
⛔ Files ignored due to path filters (2)
apps/timo-web/api/generated/models/todoCreateRequestIcon.tsis excluded by!**/generated/**apps/timo-web/api/generated/models/todoUpdateRequestIcon.tsis excluded by!**/generated/**
📒 Files selected for processing (29)
apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/todo-card/HomeTodoCard.tsxapps/timo-web/components/todo-modal/common/TodoIconField.tsxapps/timo-web/components/todo-modal/create/CreateTodoModalContent.tsxapps/timo-web/components/todo-modal/create/CreateTodoTaskFields.tsxapps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsxapps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsxapps/timo-web/containers/todo-modal/detail/DetailTodoModalContainer.tsxapps/timo-web/hooks/todo-modal/common/use-tag-field.tsxapps/timo-web/hooks/todo-modal/create/use-icon-field.tsapps/timo-web/hooks/todo-modal/create/use-repeat-field.tsapps/timo-web/hooks/todo-modal/create/use-subtask-field.tsapps/timo-web/hooks/todo-modal/create/use-time-field.tsapps/timo-web/hooks/todo-modal/detail/use-detail-subtask-field.tsapps/timo-web/hooks/todo-modal/detail/use-detail-todo-form.tsapps/timo-web/hooks/todo-modal/detail/use-detail-todo-icon-submit.tsapps/timo-web/hooks/todo-modal/detail/use-detail-todo-patch-handlers.tsapps/timo-web/hooks/todo-modal/detail/use-detail-todo-text-auto-save.tsapps/timo-web/hooks/todo-modal/detail/use-toggle-subtask-submit.tsapps/timo-web/messages/en.jsonapps/timo-web/messages/ko.jsonapps/timo-web/schemas/todo/todo-schema.tsapps/timo-web/utils/todo/detail-todo-update-request.tspackages/timo-design-system/src/components/calendar/date-selector/DateSelector.tsxpackages/timo-design-system/src/components/layout/dropdown/Dropdown.tsxpackages/timo-design-system/src/components/priority/priority-selector/PrioritySelector.tsxpackages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.stories.tsxpackages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.tsxpackages/timo-design-system/src/components/tag/tag-selector/TagSelector.tsxpackages/timo-design-system/src/components/time/time-selector/TimeSelector.tsx
| {shouldRenderPanel && ( | ||
| <div | ||
| className={cn( | ||
| "origin-top", | ||
| isPanelVisible | ||
| ? "translate-y-0 scale-100 opacity-100 transition-all duration-[220ms] ease-[cubic-bezier(0.16,1,0.3,1)]" | ||
| : "-translate-y-1.5 scale-95 opacity-0 transition-all duration-[160ms] ease-in", | ||
| )} | ||
| > | ||
| <IconSelector | ||
| selected={icon} | ||
| onSelect={onSelectIcon} | ||
| onRemove={onRemoveIcon} | ||
| /> | ||
| </div> |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
퇴장 애니메이션 중 패널이 여전히 포커스/AT에 노출됩니다.
isPanelVisible이 false가 된 뒤에도 shouldRenderPanel이 true인 160ms 동안 IconSelector는 시각적으로만 사라지고(opacity-0) DOM/tab order에는 남아 있습니다. 키보드 사용자가 이 짧은 구간에 Tab으로 진입하면 보이지 않는 버튼에 포커스가 갈 수 있습니다. isPanelVisible이 false인 동안 aria-hidden과 inert(또는 tabIndex={-1} 전파)를 함께 적용하면 안전합니다.
♿ 제안
<div
className={cn(
"origin-top",
isPanelVisible
? "translate-y-0 scale-100 opacity-100 transition-all duration-[220ms] ease-[cubic-bezier(0.16,1,0.3,1)]"
: "-translate-y-1.5 scale-95 opacity-0 transition-all duration-[160ms] ease-in",
)}
+ aria-hidden={!isPanelVisible}
+ inert={!isPanelVisible ? "" : undefined}
>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {shouldRenderPanel && ( | |
| <div | |
| className={cn( | |
| "origin-top", | |
| isPanelVisible | |
| ? "translate-y-0 scale-100 opacity-100 transition-all duration-[220ms] ease-[cubic-bezier(0.16,1,0.3,1)]" | |
| : "-translate-y-1.5 scale-95 opacity-0 transition-all duration-[160ms] ease-in", | |
| )} | |
| > | |
| <IconSelector | |
| selected={icon} | |
| onSelect={onSelectIcon} | |
| onRemove={onRemoveIcon} | |
| /> | |
| </div> | |
| {shouldRenderPanel && ( | |
| <div | |
| className={cn( | |
| "origin-top", | |
| isPanelVisible | |
| ? "translate-y-0 scale-100 opacity-100 transition-all duration-[220ms] ease-[cubic-bezier(0.16,1,0.3,1)]" | |
| : "-translate-y-1.5 scale-95 opacity-0 transition-all duration-[160ms] ease-in", | |
| )} | |
| aria-hidden={!isPanelVisible} | |
| inert={!isPanelVisible ? "" : undefined} | |
| > | |
| <IconSelector | |
| selected={icon} | |
| onSelect={onSelectIcon} | |
| onRemove={onRemoveIcon} | |
| /> | |
| </div> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/timo-web/components/todo-modal/common/TodoIconField.tsx` around lines 79
- 93, Update the exiting panel wrapper around IconSelector so that when
isPanelVisible is false it is hidden from assistive technology and removed from
keyboard interaction using aria-hidden together with inert (or propagated
tabIndex={-1}), while preserving shouldRenderPanel for the exit animation.
| <div className={canUpdateTodo ? undefined : "pointer-events-none"}> | ||
| <TodoIconField | ||
| icon={iconField.icon} | ||
| isIconPanelOpen={iconField.isIconPanelOpen} | ||
| addIconLabel={tCreateModal("addIcon")} | ||
| onOpenPanel={iconField.handleOpenIconPanel} | ||
| onTogglePanel={iconField.handleToggleIconPanel} | ||
| onSelectIcon={iconField.handleSelectIcon} | ||
| onRemoveIcon={iconField.handleRemoveIcon} | ||
| /> | ||
| </div> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
실행 중 편집 잠금을 키보드 입력에도 적용해 주세요.
pointer-events-none은 포인터만 차단하므로 자식 버튼은 탭으로 포커스한 뒤 Enter/Space로 실행할 수 있습니다. disabled를 각 컨트롤에 전달하거나, 영역에 inert를 적용하고 핸들러에서도 canUpdateTodo를 검사해 주세요. inert는 하위 요소의 포커스와 사용자 상호작용을 함께 차단합니다. (html.spec.whatwg.org)
Also applies to: 184-258
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/timo-web/components/todo-modal/detail/DetailTodoModalContent.tsx` around
lines 147 - 157, Update the TodoIconField container and the corresponding
edit-control region around the later fields so canUpdateTodo blocks keyboard
focus and activation as well as pointer interaction. Apply inert to the disabled
regions where supported, or pass disabled state to each interactive control and
guard their handlers with canUpdateTodo, while preserving normal editing
behavior when updates are allowed.
| const [isIconPanelOpen, setIsIconPanelOpen] = useState(false); | ||
| const [pendingIcon, setPendingIcon] = useState<TodoIconValue | null>(icon); | ||
|
|
||
| const handleSelectIcon = (nextIcon: TodoIconValue) => { | ||
| setPendingIcon(nextIcon); | ||
| }; | ||
| const handleOpenIconPanel = () => setIsIconPanelOpen(true); | ||
| const handleToggleIconPanel = () => setIsIconPanelOpen((prev) => !prev); | ||
|
|
||
| const handleOpenIconPanel = () => { | ||
| setPendingIcon(icon); | ||
| setIsIconPanelOpen(true); | ||
| }; | ||
|
|
||
| const handleSubmitIcon = () => { | ||
| if (!pendingIcon || pendingIcon === icon) { | ||
| const handleSelectIcon = (nextIcon: TodoIconValue) => { | ||
| if (nextIcon === icon) { | ||
| setIsIconPanelOpen(false); | ||
| return; | ||
| } | ||
|
|
||
| onUpdate( | ||
| { icon: pendingIcon }, | ||
| { icon: nextIcon }, | ||
| { | ||
| onSuccess: () => { | ||
| selectIcon(pendingIcon); | ||
| selectIcon(nextIcon); | ||
| setIsIconPanelOpen(false); | ||
| }, | ||
| }, | ||
| ); | ||
| }; | ||
|
|
||
| const handleToggleIconPanel = () => { | ||
| if (isIconPanelOpen) { | ||
| handleSubmitIcon(); | ||
| return; | ||
| } | ||
|
|
||
| handleOpenIconPanel(); | ||
| }; | ||
|
|
||
| const handleRemoveIcon = () => { | ||
| setPendingIcon(null); | ||
| onUpdate( | ||
| { icon: "NONE" }, | ||
| { | ||
| onSuccess: () => { | ||
| removeIcon(); | ||
| setIsIconPanelOpen(false); | ||
| }, | ||
| }, | ||
| ); | ||
| }; |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
아이콘 업데이트 요청을 직렬화해 주세요.
첫 요청이 완료되기 전에 다른 아이콘을 선택하거나 제거하면 PATCH 요청들이 경합합니다. 응답 처리 순서에 따라 이전 선택이 마지막 선택을 덮어쓸 수 있습니다.
요청 중에는 추가 제출을 차단하거나 최신 선택을 큐에 저장하고, onSuccess/onError에서 pending 상태를 해제해 주세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/timo-web/hooks/todo-modal/detail/use-detail-todo-icon-submit.ts` around
lines 23 - 55, Serialize icon update requests in handleSelectIcon and
handleRemoveIcon so a new selection or removal cannot start while a PATCH is
pending. Track the in-flight state around onUpdate, clear it in both onSuccess
and onError, and either block additional submissions or retain only the latest
pending choice for processing after completion.
| const handleWeekdaysChange = (weekdayIds: string[]) => { | ||
| updateTodo( | ||
| { | ||
| repeatType: "WEEKLY", | ||
| repeatWeekdays: selectedWeekdayIds.filter(isTodoUpdateRepeatWeekday), | ||
| repeatWeekdays: weekdayIds.filter(isTodoUpdateRepeatWeekday), | ||
| }, | ||
| { | ||
| onSuccess: () => form.setSelectedWeekdayIds(selectedWeekdayIds), | ||
| onSuccess: () => form.setSelectedWeekdayIds(weekdayIds), | ||
| }, | ||
| ); | ||
| }; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
선택된 요일이 없을 때의 반복 정규화 처리를 확인해 주세요.
PR 목표에 "반복 요일 미선택 시 반복 없음으로 정규화"가 명시되어 있습니다. 조회(수정) 모달에서도 모든 요일이 해제되어 weekdayIds가 빈 배열이 될 경우, repeatType을 "WEEKLY"가 아닌 "NONE"(또는 해당하는 '반복 없음' 타입)으로 전송해야 하는지 검토해 보세요. 생성 모달의 기준과 동일하다면 이 부분에도 정규화 로직 추가가 필요할 수 있습니다.
💡 수정 제안 (예시)
const handleWeekdaysChange = (weekdayIds: string[]) => {
+ const validWeekdays = weekdayIds.filter(isTodoUpdateRepeatWeekday);
updateTodo(
{
- repeatType: "WEEKLY",
- repeatWeekdays: weekdayIds.filter(isTodoUpdateRepeatWeekday),
+ repeatType: validWeekdays.length === 0 ? "NONE" : "WEEKLY",
+ repeatWeekdays: validWeekdays,
},
{
onSuccess: () => form.setSelectedWeekdayIds(weekdayIds),
},
);
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleWeekdaysChange = (weekdayIds: string[]) => { | |
| updateTodo( | |
| { | |
| repeatType: "WEEKLY", | |
| repeatWeekdays: selectedWeekdayIds.filter(isTodoUpdateRepeatWeekday), | |
| repeatWeekdays: weekdayIds.filter(isTodoUpdateRepeatWeekday), | |
| }, | |
| { | |
| onSuccess: () => form.setSelectedWeekdayIds(selectedWeekdayIds), | |
| onSuccess: () => form.setSelectedWeekdayIds(weekdayIds), | |
| }, | |
| ); | |
| }; | |
| const handleWeekdaysChange = (weekdayIds: string[]) => { | |
| const validWeekdays = weekdayIds.filter(isTodoUpdateRepeatWeekday); | |
| updateTodo( | |
| { | |
| repeatType: validWeekdays.length === 0 ? "NONE" : "WEEKLY", | |
| repeatWeekdays: validWeekdays, | |
| }, | |
| { | |
| onSuccess: () => form.setSelectedWeekdayIds(weekdayIds), | |
| }, | |
| ); | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/timo-web/hooks/todo-modal/detail/use-detail-todo-patch-handlers.ts`
around lines 103 - 113, Update handleWeekdaysChange so an empty weekdayIds array
is normalized to the no-repeat type ("NONE" or the existing equivalent) instead
of sending repeatType "WEEKLY"; preserve the filtered weekday IDs for non-empty
selections and align the behavior with the creation modal.
| const setOpenState = (next: boolean) => { | ||
| setIsOpen(next); | ||
| onOpenChange?.(next); | ||
| }; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
useEffect 내 낡은 클로저(Stale Closure) 방지를 위한 Ref 활용
현재 useEffect의 의존성 배열에서 exhaustive-deps를 비활성화(eslint-disable-next-line)했기 때문에, 컴포넌트 외부에서 onOpenChange가 변경되더라도 handleOutsideClick 안의 close 함수가 최신 콜백을 참조하지 못하는 낡은 클로저 이슈가 발생할 수 있습니다.
드롭다운이 열려있는 동안 부모 컴포넌트가 리렌더링되어 최신 상태를 담은 onOpenChange가 전달되더라도, 바깥 영역 클릭 시에는 과거의 낡은 콜백이 실행되어 변경사항이 유실될 위험이 있습니다. 안정성을 위해 useRef를 사용하여 항상 최신의 콜백을 바라보도록 수정하는 것을 권장합니다! (React 19.2 환경이라면 useEffectEvent를 활용하는 것도 좋은 대안이 될 수 있습니다.)
💡 최신 콜백을 보장하는 수정 제안
+ const onOpenChangeRef = useRef(onOpenChange);
+ onOpenChangeRef.current = onOpenChange;
+
const setOpenState = (next: boolean) => {
setIsOpen(next);
- onOpenChange?.(next);
+ onOpenChangeRef.current?.(next);
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const setOpenState = (next: boolean) => { | |
| setIsOpen(next); | |
| onOpenChange?.(next); | |
| }; | |
| const onOpenChangeRef = useRef(onOpenChange); | |
| onOpenChangeRef.current = onOpenChange; | |
| const setOpenState = (next: boolean) => { | |
| setIsOpen(next); | |
| onOpenChangeRef.current?.(next); | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/timo-design-system/src/components/layout/dropdown/Dropdown.tsx`
around lines 62 - 65, Update the Dropdown component’s setOpenState and
outside-click handling to store the latest onOpenChange callback in a ref, then
have handleOutsideClick invoke the ref’s current callback instead of a stale
closure. Keep the effect stable without relying on the disabled exhaustive-deps
warning, while preserving the existing open-state behavior.
- 스웨거 최신 스펙에서 TodoResponse/TodayTodoResponse의 hasMemo가 hasSubtask로 바뀐 것을 확인해 todoSchema와 생성된 타입을 갱신했습니다 - HomeTodoCard/TodayTodoCard의 메모 아이콘 표시 조건을 hasSubtask 기준으로 변경했습니다 - 목업 데이터도 필드명을 맞춰 정리했습니다
- 타이틀 input이 placeholder에만 의존해 접근 가능한 이름이 없던 것을, useId로 고유 id를 부여하고 sr-only label을 연결해 해결했습니다
jjangminii
left a comment
There was a problem hiding this comment.
체크아웃해서 이슈 개선된 것 확인했습니다~~ 확실히 전보다 더 편해진 것 같아요.
| (typeof TodoUpdateRequestIcon)[keyof typeof TodoUpdateRequestIcon]; | ||
|
|
||
| export const TodoUpdateRequestIcon = { | ||
| NONE: "NONE", |
| const handleSubmitIcon = () => { | ||
| if (!pendingIcon || pendingIcon === icon) { | ||
| const handleSelectIcon = (nextIcon: TodoIconValue) => { | ||
| if (nextIcon === icon) { |
There was a problem hiding this comment.
전체적으로 선택 -> 임시저장 -> 제출이라는 3단계 플로우를 선택 = 즉시 적용 1단계로 줄이면서 코드도 간결해지고 UX도 더 직관적으로 개선됐네요~~
ehye1
left a comment
There was a problem hiding this comment.
각 드롭다운에서 서버와 동기화되는 시점을 명확하게 분리하는 게 중요한 것 같아요. draft와 실제 상태를 분리함으로써 불필요한 상태 변경을 줄여서 좋은 것 같아요.
qa 반영하느라 수고많으셨어요!✌🏻
ISSUE 🔗
close #238
What is this PR? 🔍
투두 생성/조회 모달과 홈 카드 전반에 걸쳐 발견된 UX 이슈와 버그를 수정했습니다.
배경
NONEenum이 없어 아예 반영되지 않았습니다. 우선순위는 설정하지 않아도 서버 스키마가MEDIUM으로 기본값을 채워 넣어 홈 카드에 항상 아이콘이 노출됐습니다.NONEenum을 추가했습니다. 우선순위·반복 요일 등 값이 없을 때의 기본값 처리는 스키마 단에서 실제 상태를 그대로 보존하도록 정규화했습니다.드롭다운 선택 컴포넌트 (태그 · 시간 · 우선순위 · 반복 · 날짜)
Dropdown.Item이 기본값closeOnSelect=true라 클릭 즉시 패널이 닫혀버려서, 이미 적용돼 있던 "닫힐 때 커밋" 패턴(Date/Priority/Repeat 일부)이 실제로는 동작하지 않고 있었습니다.useState+useRef)를 두고Dropdown의onOpenChange콜백에서 열릴 때 draft를 현재 값으로 초기화, 닫힐 때 draft가 바뀌었으면onSelect를 호출하도록 했습니다. 항목 클릭 시에는closeOnSelect={false}를 명시해 패널이 유지되도록 했습니다. TimeSelector는 열려 있는 동안 AI 추천 응답처럼 외부에서selected/timeprop이 바뀌는 경우를 위해useEffect로 draft를 prop에 동기화하는 로직을 추가했습니다.조회 모달 — 날짜 변경
currentDate로컬 상태를 두고, 날짜 변경 PATCH가 성공한 시점에만 이 값을 갱신하도록 했습니다. 상세 조회는 이currentDate를 쿼리 키로 사용하고,placeholderData: keepPreviousData를 적용해 쿼리 키가 바뀌는 동안에도 이전 데이터를 계속 보여주다가 새 데이터가 오면 자연스럽게 전환되도록 했습니다.조회 모달 — 타이틀 · 태그 · 하위 태스크
<textarea>라 줄바꿈이 가능했고 Enter 키 핸들러가 없어 그냥 개행이 삽입됐습니다. 태그 추가 모달에서 새 태그를 만들어도 로컬 상태만 바뀌고 투두에 반영되지 않았고, 하위 태스크 완료는 투두 전체를 다시 PATCH하는 방식이라 백엔드에 이미 있는 전용 상태 변경 API(PATCH /todos/{id}/subtasks/{id}/status)를 활용하지 못하고 있었습니다.<input type="text">로 바꾸고 Enter 입력 시preventDefault후 첫 번째 하위 태스크 입력창으로 포커스를 이동시킵니다. 태그 생성은useTagField에onTagCreated(tagId, syncLocal)콜백을 추가해, 태그 생성 API 성공 후 투두 PATCH까지 마친 뒤에만 로컬 상태를 동기화하도록 했습니다. 하위 태스크 완료는useToggleSubtaskSubmit이라는 전용 훅을 새로 만들어changeSubtaskStatus를 호출하고, 성공 시 home/today/상세 쿼리를 무효화합니다.DetailTodoSubtaskInput)을 훅(use-detail-subtask-field.ts)이 아니라 이를 렌더링하는 프레젠테이션 컴포넌트(DetailTodoTaskFields.tsx)가 소유하도록 옮겼습니다. 리팩토링 과정에서 한 차례 react-hook-form을 완전히 걷어내고 plain state로 옮기는 시도를 했으나, 파일이 지나치게 커지고 원래 구조(폼/PATCH 오케스트레이션/아이콘/자동저장/하위태스크로 관심사가 나뉜 5개 훅)가 이미 합리적으로 분리돼 있었다고 판단해 되돌렸습니다.생성 모달 — 타이틀 · 하위 태스크 포커스 · 반복 요일 검증
createTodoRequestSchema의superRefine이 검증 에러를 던져 제출 자체가 막혔는데, 화면에 에러 메시지가 노출되지 않아 "생성하기를 눌러도 반응이 없다"처럼 보였습니다.superRefine앞에.transform을 추가해, WEEKLY인데 요일이 비어 있으면repeatType을NONE으로,repeatWeekdays를null로 정규화하도록 했습니다. MONTHLY(날짜 미선택) 검증은 이번 범위에 포함하지 않아 기존 동작(제출 차단)을 그대로 유지했습니다.아이콘 선택 · 제거
pendingIcon을 비웠는데, 로컬 생성된TodoUpdateRequestIcon/TodoCreateRequestIconenum에NONE값이 없어서 애초에 "제거"를 표현할 방법이 없었습니다./v3/api-docs)를 직접 조회해iconenum에 실제로NONE이 존재함을 확인했습니다. 전체pnpm gen:api재생성은 캘린더 신규 엔드포인트 등 무관한 변경을 대량으로 끌고 와서, 두 enum 파일에NONE: "NONE"만 최소로 수동 반영했습니다.handleSelectIcon/handleRemoveIcon을 draft 없이 즉시onUpdatePATCH를 호출하고 성공 시 로컬 상태를 동기화 + 패널을 닫도록 재작성했습니다. 패널 열림/닫힘 애니메이션도 상단 버튼 전환 시점을 패널이 완전히 사라진 뒤로 맞춰 끊김을 줄였습니다.TodoCreateRequest.icon은 optional이라 CREATE 모달은 원래부터 아이콘 미선택 시 필드를 생략하는 방식으로 문제없이 동작하고 있어 별도로 손대지 않았습니다.투두 카드 — 우선순위 기본값 · 호버 효과
todoSchema가priority가 없을 때MEDIUM으로 강제 대체하고 있어, 태그 · 메모 · 반복 아이콘은 값이 있을 때만 조건부로 보이는데 우선순위만 항상 보이는 비일관성이 있었습니다.priority ?? "MEDIUM"을priority ?? undefined로 바꾸고,HomeTodoCard의priorityprop을 optional로 변경해 값이 있을 때만PriorityIcon을 렌더링합니다. 완료된 카드는isHovered상태를 추가해 기본은 dim 처리하되 호버 중에는 일시적으로 활성 카드처럼 밝게 보이도록 했습니다(체크박스 · 재생 버튼 등 실제 기능 상태는 hover와 무관하게 유지).우선순위 번역 키 정리
Common.priority번역 키를urgent/high/medium/low에서 API enum 값(VERY_HIGH/HIGH/MEDIUM/LOW)으로 바꿨습니다.HomeTodoCard에 API 값 → 번역 키를 매핑하는 별도 테이블(PRIORITY_LABEL_KEY)이 있었는데, 번역 키 자체를 API 값과 맞추면 이 매핑이 불필요해집니다.en.json/ko.json의 키 이름만 바꾸고, 이를 참조하던DetailTodoModalContent.tsx·CreateTodoModalContent.tsx·HomeTodoCard.tsx의tCommon("priority.xxx")호출부와 매핑 테이블을 정리했습니다.To Reviewers
use-detail-todo-form/use-detail-todo-patch-handlers/use-detail-todo-icon-submit/use-detail-todo-text-auto-save/use-detail-subtask-field)를 유지했는데, 이 판단이 맞는지 한 번 봐주시면 좋겠습니다.NONEenum은pnpm gen:api전체 재생성 대신 두 파일에 수동으로 값을 추가했습니다. 다음에 정말 전체 재생성이 필요한 시점(예: 캘린더 기능 착수)에는 이번에 보류한 다른 API 변경사항들도 함께 반영될 수 있다는 점 참고해 주세요.TIMER_409가 발생하는 이슈를 세션 중 발견했으나, 수정 방향에 대한 확신이 부족해 이번 PR에는 포함하지 않았습니다. 별도 이슈로 트래킹이 필요합니다.Screenshot 📷
Test Checklist ✔
pnpm check-types(apps/timo-web) 통과pnpm lint(apps/timo-web) 통과tsc --noEmit(packages/timo-design-system) 통과eslint(packages/timo-design-system) 통과pnpm build— 미실행