Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export const HomeTodoContainer = () => {
onStopFeedback: setFeedbackText,
onPlayError: (message) =>
setPlayErrorMessage(message ?? tToast("timerStartFailed")),
onUpdateError: (message) =>
setPlayErrorMessage(message ?? tToast("todoUpdateFailed")),
});

const handleConfirmPendingComplete = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";

import type { ErrorType } from "@/api/client/custom-instance";
import type { ApiError } from "@/api/error/api-error";
import type { ErrorDto } from "@/api/generated/models";
import type { HomeViewDay } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type";
import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type";

Expand All @@ -28,6 +30,7 @@ export interface UseHomeTodosByDateOptions {
onTimerAlreadyRunning: () => void;
onStopFeedback: (feedbackText: string | undefined) => void;
onPlayError: (message: string | undefined) => void;
onUpdateError: (message: string | undefined) => void;
}

export const useHomeTodosByDate = (
Expand All @@ -37,6 +40,7 @@ export const useHomeTodosByDate = (
onTimerAlreadyRunning,
onStopFeedback,
onPlayError,
onUpdateError,
}: UseHomeTodosByDateOptions,
) => {
const [todosByDate, setTodosByDate] = useState<Record<string, Todo[]>>({});
Expand Down Expand Up @@ -133,8 +137,9 @@ export const useHomeTodosByDate = (
invalidateTodoDetail(dateKey, todoId);
invalidateStatistics();
},
onError: () => {
onError: (error: ErrorType<ErrorDto>) => {
setTodosByDate((prev) => ({ ...prev, [dateKey]: previous }));
onUpdateError(error.response?.data.message);
},
},
);
Expand Down Expand Up @@ -237,8 +242,9 @@ export const useHomeTodosByDate = (
invalidateTodoDetail(dateKey, todoId);
invalidateStatistics();
},
onError: () => {
onError: (error: ErrorType<ErrorDto>) => {
setTodosByDate((prev) => ({ ...prev, [dateKey]: previous }));
onUpdateError(error.response?.data.message);
},
},
);
Expand Down Expand Up @@ -270,8 +276,9 @@ export const useHomeTodosByDate = (
{ todoId: movedTodo.todoId, data: { newIndex: toIndex, date: dateKey } },
{
onSuccess: invalidateHomeAndFocus,
onError: () => {
onError: (error: ErrorType<ErrorDto>) => {
setTodosByDate((prev) => ({ ...prev, [dateKey]: previous }));
onUpdateError(error.response?.data.message);
},
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export const TodayTodoListContainer = () => {
onStopFeedback: setFeedbackText,
onPlayError: (message) =>
setPlayErrorMessage(message ?? tToast("timerStartFailed")),
onUpdateError: (message) =>
setPlayErrorMessage(message ?? tToast("todoUpdateFailed")),
});

const handleConfirmPendingComplete = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface UseTodayTodoListOptions {
onTimerAlreadyRunning: () => void;
onStopFeedback: (feedbackText: string | undefined) => void;
onPlayError: (message: string | undefined) => void;
onUpdateError: (message: string | undefined) => void;
}

export const useTodayTodoList = (
Expand All @@ -35,6 +36,7 @@ export const useTodayTodoList = (
onTimerAlreadyRunning,
onStopFeedback,
onPlayError,
onUpdateError,
}: UseTodayTodoListOptions,
) => {
const [todos, setTodos] = useState<TodayTodo[]>(initialTodos);
Expand Down Expand Up @@ -118,8 +120,9 @@ export const useTodayTodoList = (
invalidateTodoDetail(todoId, dateKey);
invalidateFocusTodo();
},
onError: () => {
onError: (error: ErrorType<ErrorDto>) => {
setTodos(previous);
onUpdateError(error.response?.data.message);
Comment on lines +123 to +125

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

상태 롤백 시 동시 업데이트(Concurrent Update) 유실을 방지해 보세요.

에러 발생 시 previous 배열 전체로 상태를 덮어씌우면, 서버 요청이 진행되는 동안 사용자가 다른 투두를 수정했을 때 그 변경 사항까지 함께 날아갈 수 있습니다. 배열 전체를 되돌리기보다는, 변경을 시도했던 특정 투두의 상태만 함수형 업데이트(setTodos((prev) => ...))로 원상 복구하는 방식이 더 안전합니다. 😊

(공식 문서 참고: React State Updates)

Also applies to: 214-216

🤖 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/app/`[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts
around lines 102 - 104, Update the onError handler in the today todo mutation to
avoid restoring the entire previous array; use a functional setTodos update that
reverts only the specific todo changed by the failed request while preserving
concurrent edits to other todos, then keep the existing onUpdateError call.

},
},
);
Expand Down Expand Up @@ -237,8 +240,9 @@ export const useTodayTodoList = (
invalidateTodoDetail(todoId, dateKey);
invalidateFocusTodo();
},
onError: () => {
onError: (error: ErrorType<ErrorDto>) => {
setTodos(previous);
onUpdateError(error.response?.data.message);
},
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export const useFocusSession = ({
invalidateHomeView();
invalidateTimeBoxes();
invalidateTodayView();
invalidateTodoDetail();
},
onError: onMutationError,
},
Expand All @@ -108,6 +109,7 @@ export const useFocusSession = ({
invalidateHomeView();
invalidateTimeBoxes();
invalidateTodayView();
invalidateTodoDetail();
},
onError: onMutationError,
},
Expand Down
3 changes: 3 additions & 0 deletions apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const TimerPanel = () => {
invalidateTimeBoxes,
invalidateTodayView,
invalidateFocusTodo,
invalidateTodoDetail,
} = useTimerQueryInvalidation();

const { mutate: changeStatus } = useChangeStatus({
Expand Down Expand Up @@ -63,6 +64,7 @@ export const TimerPanel = () => {
invalidateTimeBoxes();
invalidateTodayView();
invalidateFocusTodo();
if (response.data?.todoId) invalidateTodoDetail(response.data.todoId);
},
},
});
Expand All @@ -75,6 +77,7 @@ export const TimerPanel = () => {
invalidateTimeBoxes();
invalidateTodayView();
invalidateFocusTodo();
if (response.data?.todoId) invalidateTodoDetail(response.data.todoId);
},
},
});
Expand Down
44 changes: 33 additions & 11 deletions apps/timo-web/components/modal/OverlayModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,19 +114,41 @@ export const OverlayModal = ({
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
tabIndex={-1}
className="fixed inset-0 overflow-y-auto"
style={{ zIndex: panelZIndex }}
className={cn(
"fixed top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col rounded-[4px] bg-white transition-all duration-200 ease-out",
isVisible ? "scale-100 opacity-100" : "scale-95 opacity-0",
className,
)}
>
{children}
{/*
배경 클릭으로 모달을 닫는 영역이다. 포인터 전용 UX이며 키보드
사용자는 이미 Escape로 동일하게 닫을 수 있어(위 handleEscape),
여기서는 jsx-a11y의 인터랙션 규칙을 의도적으로 예외 처리한다.
*/}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
<div
className="flex min-h-full items-center justify-center py-8"
onPointerDownCapture={() => {
wasFloatingLayerOpenRef.current = hasOpenFloatingLayer();
}}
onClick={(event) => {
if (event.target !== event.currentTarget) return;
if (wasFloatingLayerOpenRef.current) return;
onClose();
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
tabIndex={-1}
className={cn(
"flex flex-col rounded-[4px] bg-white transition-all duration-200 ease-out",
isVisible ? "scale-100 opacity-100" : "scale-95 opacity-0",
className,
)}
>
Comment on lines +126 to +148

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Flexbox의 콘텐츠 잘림 현상을 방지하기 위해 m-auto 활용을 추천해요.

모달이 뷰포트보다 길어질 때 잘리는 문제를 해결하기 위해 flex 레이아웃을 도입하신 점 멋집니다! 👍

다만, 부모 요소에 items-centerjustify-center를 사용하면 내부 콘텐츠가 뷰포트보다 길어질 때 위쪽(Top) 영역이 화면 밖으로 잘려서 스크롤로도 도달할 수 없는 CSS Flexbox의 고질적인 문제가 발생할 수 있어요.

이를 방지하려면 부모의 items-center justify-center를 제거하고, 자식 요소(모달창)에 m-auto를 부여하는 방식을 제안합니다. 이렇게 하면 모달이 작을 때는 완벽하게 중앙 정렬되고, 길어지면 자연스럽게 상단부터 스크롤되도록 만들 수 있습니다. 💡

접근성 및 Flexbox 동작에 대한 MDN 문서도 참고해 보시면 레이아웃 설계에 큰 도움이 될 거예요!

✨ 제안하는 CSS 레이아웃 수정안
-          <div
-            className="flex min-h-full items-center justify-center py-8"
-            onPointerDownCapture={() => {
-              wasFloatingLayerOpenRef.current = hasOpenFloatingLayer();
-            }}
-            onClick={(event) => {
-              if (event.target !== event.currentTarget) return;
-              if (wasFloatingLayerOpenRef.current) return;
-              onClose();
-            }}
-          >
-            <div
-              ref={dialogRef}
-              role="dialog"
-              aria-modal="true"
-              aria-label={ariaLabel}
-              tabIndex={-1}
-              className={cn(
-                "flex flex-col rounded-[4px] bg-white transition-all duration-200 ease-out",
-                isVisible ? "scale-100 opacity-100" : "scale-95 opacity-0",
-                className,
-              )}
-            >
+          <div
+            className="flex min-h-full py-8"
+            onPointerDownCapture={() => {
+              wasFloatingLayerOpenRef.current = hasOpenFloatingLayer();
+            }}
+            onClick={(event) => {
+              if (event.target !== event.currentTarget) return;
+              if (wasFloatingLayerOpenRef.current) return;
+              onClose();
+            }}
+          >
+            <div
+              ref={dialogRef}
+              role="dialog"
+              aria-modal="true"
+              aria-label={ariaLabel}
+              tabIndex={-1}
+              className={cn(
+                "m-auto flex flex-col rounded-[4px] bg-white transition-all duration-200 ease-out",
+                isVisible ? "scale-100 opacity-100" : "scale-95 opacity-0",
+                className,
+              )}
+            >
📝 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.

Suggested change
<div
className="flex min-h-full items-center justify-center py-8"
onPointerDownCapture={() => {
wasFloatingLayerOpenRef.current = hasOpenFloatingLayer();
}}
onClick={(event) => {
if (event.target !== event.currentTarget) return;
if (wasFloatingLayerOpenRef.current) return;
onClose();
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
tabIndex={-1}
className={cn(
"flex flex-col rounded-[4px] bg-white transition-all duration-200 ease-out",
isVisible ? "scale-100 opacity-100" : "scale-95 opacity-0",
className,
)}
>
<div
className="flex min-h-full py-8"
onPointerDownCapture={() => {
wasFloatingLayerOpenRef.current = hasOpenFloatingLayer();
}}
onClick={(event) => {
if (event.target !== event.currentTarget) return;
if (wasFloatingLayerOpenRef.current) return;
onClose();
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
tabIndex={-1}
className={cn(
"m-auto flex flex-col rounded-[4px] bg-white transition-all duration-200 ease-out",
isVisible ? "scale-100 opacity-100" : "scale-95 opacity-0",
className,
)}
>
🤖 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/modal/OverlayModal.tsx` around lines 99 - 121,
Update the modal layout around the wrapper div and dialogRef element: remove the
parent’s items-center and justify-center alignment classes, and add m-auto to
the dialog’s className composition. Preserve the existing visibility, sizing,
and transition classes so small modals remain centered while oversized content
can scroll from the top.

{children}
</div>
</div>
</div>
</FocusTrap>
</>,
Expand Down
6 changes: 6 additions & 0 deletions apps/timo-web/hooks/timer/use-timer-query-invalidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "@/api/generated/endpoints/home/home";
import { getGetTimeBoxesQueryKey } from "@/api/generated/endpoints/time-box/time-box";
import { getGetActiveTimerQueryKey } from "@/api/generated/endpoints/timer/timer";
import { getGetTodoDetailQueryKey } from "@/api/generated/endpoints/todo/todo";
import { useStatisticsQueryInvalidation } from "@/hooks/statistics/use-statistics-query-invalidation";

export const useTimerQueryInvalidation = () => {
Expand All @@ -25,6 +26,10 @@ export const useTimerQueryInvalidation = () => {
queryClient.invalidateQueries({ queryKey: getGetTodayQueryKey() });
const invalidateFocusTodo = () =>
queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() });
const invalidateTodoDetail = (todoId: number) =>
queryClient.invalidateQueries({
queryKey: getGetTodoDetailQueryKey(todoId),
});
const invalidateTimerState = () => {
invalidateActiveTimer();
invalidateHomeView();
Expand All @@ -39,6 +44,7 @@ export const useTimerQueryInvalidation = () => {
invalidateTimeBoxes,
invalidateTodayView,
invalidateFocusTodo,
invalidateTodoDetail,
invalidateTimerState,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const DropdownView = ({
role="option"
aria-selected={isSelected}
onClick={() => onChange(item)}
className="typo-body-m-12 text-timo-gray-900 px-1.5 py-1"
className="typo-body-m-12 text-timo-gray-900 py-1"
>
{item}
</Dropdown.Item>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface RepeatSelectorProps {
frequencyHeading?: string;
detailHeading: string;
options: RepeatOption[];
isActive?: boolean;
frequency: RepeatFrequency;
onFrequencyChange?: (frequency: RepeatFrequency) => void;
weekly: RepeatWeeklyDetail;
Expand Down Expand Up @@ -158,6 +159,7 @@ export const RepeatSelector = ({
frequencyHeading = "반복 일정",
detailHeading,
options,
isActive = true,
frequency,
onFrequencyChange,
weekly,
Expand All @@ -174,12 +176,18 @@ export const RepeatSelector = ({
const draftFrequencyRef = useRef(frequency);
const draftWeekdayIdsRef = useRef(weekly.selectedWeekdayIds);
const draftRepeatDayRef = useRef(monthly.repeatDay);
// 반복이 꺼져 있을 때(isActive=false) frequency는 "DAILY"라는 기본값을 표시할
// 뿐 실제로 저장된 값이 아니다. 이 경우 "매일"을 선택해도 draft가 기본값과
// 같아서 "변경 없음"으로 오인되므로, 사용자가 실제로 클릭했는지를 별도로
// 추적해 그 경우엔 값이 같아도 반드시 커밋한다.
const hasSelectedFrequencyRef = useRef(false);

const selectedLabel = options.find(
(option) => option.frequency === draftFrequency,
)?.label;

const handleSelectFrequency = (value: RepeatFrequency) => {
hasSelectedFrequencyRef.current = true;
draftFrequencyRef.current = value;
setDraftFrequency(value);
if (value !== "DAILY") setIsPicking(false);
Expand All @@ -201,6 +209,7 @@ export const RepeatSelector = ({

const handleOpenChange = (isOpen: boolean) => {
if (isOpen) {
hasSelectedFrequencyRef.current = false;
draftFrequencyRef.current = frequency;
setDraftFrequency(frequency);
draftWeekdayIdsRef.current = weekly.selectedWeekdayIds;
Expand All @@ -210,7 +219,10 @@ export const RepeatSelector = ({
return;
}

if (draftFrequencyRef.current !== frequency) {
if (
hasSelectedFrequencyRef.current &&
(draftFrequencyRef.current !== frequency || !isActive)
) {
onFrequencyChange?.(draftFrequencyRef.current);
}
if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export const TodoToolbar = ({

<RepeatSelector
{...repeat}
isActive={isRepeatActive}
trigger={(isOpen) =>
isOpen ? (
<RepeatBlueIcon />
Expand Down
Loading