Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
a806556
feat(web): 타이머 정지 API를 연동한다 (#162)
jjangminii Jul 13, 2026
a729882
feat(web): 홈/사이드바 타이머 API를 연동한다 (#162)
jjangminii Jul 13, 2026
5fb6a9e
fix(web): 타이머 완료/중지 판정 기준을 시간 소진 여부로 수정한다 (#162)
jjangminii Jul 13, 2026
6989e1a
refactor(web): 활성 타이머 폴링을 클라이언트 카운트다운으로 대체한다 (#162)
jjangminii Jul 13, 2026
687269e
fix(web): 포커스 태스크 아이템의 시간 배지가 연장분을 반영하지 않던 문제를 수정한다 (#162)
jjangminii Jul 13, 2026
b6cbf99
feat(web): 시간 소진 시 종료 모달 자동 표시 및 연장 구간 오버타임 링을 연동한다 (#162)
jjangminii Jul 13, 2026
6a19c33
refactor(web): FocusSessionContainer의 로직을 use-focus-session 훅으로 분리한다 …
jjangminii Jul 13, 2026
967b55f
feat(web): 새로고침·페이지 이동 후에도 오버타임 링 상태를 유지한다 (#162)
jjangminii Jul 13, 2026
d2f65c2
fix(web): 타이머 mutation 성공 시 홈 뷰 쿼리도 함께 무효화한다 (#162)
jjangminii Jul 13, 2026
a228e4f
refactor(web): 타이머 plannedLabel 표시 형식을 분 단위로 단순화한다 (#162)
jjangminii Jul 13, 2026
3c8366d
fix(ui): z-index 토큰 이름을 --z-index-* 컨벤션에 맞게 수정한다 (#162)
jjangminii Jul 13, 2026
ddbb03c
Merge branch 'develop' of https://github.com/Team-Timo/Timo-client in…
jjangminii Jul 13, 2026
909490e
fix(web): 홈 투두 카드의 재생 아이콘이 활성 타이머 상태를 반영하지 않던 문제를 수정한다 (#162)
jjangminii Jul 13, 2026
1379f3b
fix(web): 타이머 시간 표시에서 1분 미만 초를 내림 대신 올림 처리한다 (#162)
jjangminii Jul 13, 2026
6059533
fix(web): 시간 소진 시 타이머를 자동 일시정지하고 연장 시 재개한다 (#162)
jjangminii Jul 13, 2026
afcd505
fix(web): 타이머 링이 값이 몰아서 바뀔 때만 트랜지션 없이 스냅되게 한다 (#162)
jjangminii Jul 13, 2026
4f6b827
fix(web): 타이머 정지 시 투두 완료 처리 API를 함께 호출한다 (#162)
jjangminii Jul 13, 2026
b508fa0
fix(web): 종료 버튼 클릭 시 모달 분기 기준을 재생 여부 대신 시간 소진 여부로 수정한다 (#162)
jjangminii Jul 13, 2026
1ef1af2
fix(web): 홈에서 투두 순서 변경 시 포커스 페이지 쿼리도 무효화한다 (#162)
jjangminii Jul 13, 2026
592a3e4
fix(web): 타이머 정지 성공 후에만 투두 완료 상태를 변경하도록 순서를 보장한다 (#162)
jjangminii Jul 14, 2026
7da8e77
fix(web): 홈에서 투두/서브태스크 완료 처리 시 포커스 페이지 쿼리도 무효화한다 (#162)
jjangminii Jul 14, 2026
425da74
feat(web): 홈 투두 카드 체크박스도 포커스 페이지와 동일하게 정지 확인 모달을 거치도록 한다 (#162)
jjangminii Jul 14, 2026
e3c01dc
feat(web): 다른 투두의 타이머가 실행 중일 때 재생을 막고 토스트로 안내한다 (#162)
jjangminii Jul 14, 2026
786b8b2
refactor(web): 활성 타이머/홈 뷰 쿼리 무효화 로직을 useTimerQueryInvalidation 훅으로 공통…
jjangminii Jul 14, 2026
a829f7c
refactor(web): 타이머 액션 핸들러를 useTimerActions 훅으로 공통화한다 (#162)
jjangminii Jul 14, 2026
2162b89
fix(web): Timer의 렌더 간격 비교용 ref 갱신을 렌더 이후로 옮긴다 (#162)
jjangminii Jul 14, 2026
ea5bf7e
refactor(web): 홈 뷰/포커스 쿼리 무효화 중복 호출을 invalidateHomeAndFocus로 묶는다 (#162)
jjangminii Jul 14, 2026
75210bc
fix(web): 오버타임 연장 시 RESUME 성공 후에만 extendTimer를 호출하도록 순서를 보장한다 (#162)
jjangminii Jul 14, 2026
1c440ff
fix(web): 오버타임 저장값 초기화를 하이드레이션 안전하게 하고 손상된 값을 방어한다 (#162)
jjangminii Jul 14, 2026
1716114
feat(web): 타이머 아이콘을 투두가 선택한 IconGraphic으로 교체한다 (#162)
jjangminii Jul 14, 2026
d29ba5d
Merge branch 'develop' of https://github.com/Team-Timo/Timo-client in…
jjangminii Jul 14, 2026
63453d4
Merge branch 'develop' of https://github.com/Team-Timo/Timo-client in…
jjangminii Jul 14, 2026
2a02b18
feat(web): 포커스 투두 메모를 연동하고 icon 검증 크래시를 방지한다 (#162)
jjangminii Jul 14, 2026
000bcf0
refactor(web): 홈 모달/토스트 UI 상태를 훅에서 컨테이너로 옮긴다 (#162)
jjangminii Jul 14, 2026
7705fe5
fix(web): 계획 시간이 60분을 넘으면 시/분 형식으로 표시한다 (#162)
jjangminii Jul 14, 2026
95c6e06
feat(web): 사이드바 타임박스 패널에 타임박스 API를 연동한다 (#162)
jjangminii Jul 14, 2026
8cde952
feat(web): 타이머 조작과 투두 체크 시 타임박스 쿼리도 함께 무효화한다 (#162)
jjangminii Jul 14, 2026
eab4ad3
fix(web): 타임박스가 같은 timerId면 끊긴 구간도 진행 중으로 표시한다 (#162)
jjangminii Jul 14, 2026
b072d04
style(web): 타임박스 라벨 폰트를 11px로 줄이고 세로 중앙정렬로 바꾼다 (#162)
jjangminii Jul 14, 2026
996f97e
style(web): 타임박스 패널 행 높이와 라벨 배치를 조정한다 (#162)
jjangminii Jul 14, 2026
7c5a0bd
Update apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_co…
jjangminii Jul 14, 2026
1de49a8
Update apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-sessi…
jjangminii Jul 14, 2026
d21a381
refactor(web): 포커스 세션 UI 상태를 컨테이너로 옮긴다 (#162)
jjangminii Jul 14, 2026
124cf57
refactor(web): 트리비얼 토스트 닫기 핸들러를 인라인한다 (#162)
jjangminii Jul 14, 2026
36896d7
chore(web): 이름을 그대로 되풀이하는 불필요한 주석을 제거한다 (#162)
jjangminii Jul 14, 2026
7fe278f
Merge branch 'feat/web/162-timer-api-integration' of https://github.c…
jjangminii Jul 14, 2026
3ef3556
chore(web): 남은 주석을 마저 제거한다 (#162)
jjangminii Jul 14, 2026
10d7c8c
Merge branch 'develop' of https://github.com/Team-Timo/Timo-client in…
jjangminii Jul 14, 2026
bb7737b
chore(web): 남은 주석을 마저 제거한다 (#162)
jjangminii Jul 14, 2026
bac9d73
rename(web): HomeStopCompleteModal을 HomeStopCompleteModalContainer로 이…
jjangminii Jul 14, 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 @@ -2,26 +2,36 @@

import { cn } from "@repo/timo-design-system/utils";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useEffect, useState } from "react";

import type { HomeViewFilter } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type";

import { HomeTodoCard } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_components/todo-card/HomeTodoCard";
import { HomeDayHeaderContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeDayHeaderContainer";
import { HomeStopCompleteModalContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeStopCompleteModalContainer";
import {
scrollContainerToToday,
useHomeTodayScrollRef,
} from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-today-scroll";
import { useHomeTodosByDate } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date";
import { useHomeViewMode } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-view-mode";
import { useHomeView } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view";
import { AnimatedToast } from "@/components/toast/AnimatedToast";
import { DetailTodoModalContainer } from "@/components/todo-modal/detail/DetailTodoModalContainer";
import { DndSortableListProvider } from "@/providers/dnd/DndSortableListProvider";
import { formatDateKey } from "@/utils/date/date";
import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes";
import { getDefaultTagLabelKey } from "@/utils/todo/tag-label";

interface PendingCompleteTodo {
token: number;
dateKey: string;
todoId: number;
}

export const HomeTodoContainer = () => {
const tCommon = useTranslations("Common");
const tToast = useTranslations("Toast");
const { isWeekView, referenceDate } = useHomeViewMode();

const scrollRef = useHomeTodayScrollRef<HTMLDivElement>();
Expand All @@ -32,19 +42,51 @@ export const HomeTodoContainer = () => {
const { data: homeViewData } = useHomeView({ filter, baseDate });
const days = homeViewData.days;

const [pendingCompleteTodo, setPendingCompleteTodo] =
useState<PendingCompleteTodo | null>(null);
const [feedbackText, setFeedbackText] = useState<string | undefined>();
const [isTimerRunningToastOpen, setIsTimerRunningToastOpen] = useState(false);

const {
todosByDate,
activeTimer,
handleToggleCompleted,
handleTogglePlay,
handleToggleSubtaskCompleted,
handleDeleteTodo,
handleReorderTodo,
} = useHomeTodosByDate(days);
confirmStopAndComplete,
} = useHomeTodosByDate(days, {
onNeedStopConfirm: (dateKey, todoId) =>
setPendingCompleteTodo({ token: Date.now(), dateKey, todoId }),
onTimerAlreadyRunning: () => setIsTimerRunningToastOpen(true),
onStopFeedback: setFeedbackText,
});

const handleConfirmPendingComplete = () => {
if (!pendingCompleteTodo) return;

confirmStopAndComplete(
pendingCompleteTodo.dateKey,
pendingCompleteTodo.todoId,
);

setPendingCompleteTodo(null);
Comment thread
jjangminii marked this conversation as resolved.
};

useEffect(() => {
scrollContainerToToday(scrollRef.current);
}, [isWeekView, scrollRef]);

const plannedMinutes = activeTimer
? convertDurationToMinutes(
activeTimer.plannedSeconds + activeTimer.extendedSeconds,
)
: 0;
const actualMinutes = activeTimer
? convertDurationToMinutes(activeTimer.elapsedSeconds)
: 0;

return (
<div
ref={scrollRef}
Expand Down Expand Up @@ -88,6 +130,14 @@ export const HomeTodoContainer = () => {
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto pr-1">
{todos.map((todo) => {
const [firstSubtask] = todo.subtasks;
const isActiveTodo =
activeTimer && activeTimer.todoId === todo.todoId;
const durationSeconds = isActiveTodo
? activeTimer.plannedSeconds + activeTimer.extendedSeconds
: todo.durationSeconds;
const timerStatus = isActiveTodo
? activeTimer.status
: todo.timerStatus;

const todoTagLabelKey = todo.tag
? getDefaultTagLabelKey(todo.tag.tagId)
Expand All @@ -112,12 +162,12 @@ export const HomeTodoContainer = () => {
todoId={todo.todoId}
title={todo.title}
isCompleted={todo.completed}
durationSeconds={todo.durationSeconds}
durationSeconds={durationSeconds}
priority={todo.priority}
tagName={todoTagName}
hasMemo={todo.hasMemo}
isRepeated={todo.isRepeated}
timerStatus={todo.timerStatus}
timerStatus={timerStatus}
subtaskTitle={firstSubtask?.content}
isSubtaskCompleted={firstSubtask?.completed}
onClickTodo={openDetailTodoModal}
Expand Down Expand Up @@ -152,6 +202,20 @@ export const HomeTodoContainer = () => {
</div>
);
})}

<HomeStopCompleteModalContainer
pendingToken={pendingCompleteTodo?.token ?? null}
plannedMinutes={plannedMinutes}
actualMinutes={actualMinutes}
feedbackText={feedbackText}
onConfirm={handleConfirmPendingComplete}
/>

<AnimatedToast
isOpen={isTimerRunningToastOpen}
onClose={() => setIsTimerRunningToastOpen(false)}
message={tToast("timerAlreadyRunning")}
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use client";

import { Modal } from "@repo/timo-design-system/ui";
import { useEffect, useRef, useState } from "react";

import { TimerCompleteModalPanel } from "@/components/timer/TimerCompleteModalPanel";
import { TimerStopModalPanel } from "@/components/timer/TimerStopModalPanel";

export interface HomeStopCompleteModalContainerProps {
pendingToken: number | null;
plannedMinutes: number;
actualMinutes: number;
feedbackText?: string;
onConfirm: () => void;
}

export const HomeStopCompleteModalContainer = ({
pendingToken,
plannedMinutes,
actualMinutes,
feedbackText,
onConfirm,
}: HomeStopCompleteModalContainerProps) => {
const [step, setStep] = useState<"stop" | "complete">("stop");
const hiddenTriggerRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
if (pendingToken === null) return;

setStep("stop");
hiddenTriggerRef.current?.click();
}, [pendingToken]);

return (
<Modal>
<Modal.Trigger
ref={hiddenTriggerRef}
className="hidden"
aria-hidden="true"
tabIndex={-1}
/>
<Modal.Overlay />
<Modal.Panel>
{step === "stop" ? (
<TimerStopModalPanel
minutes={actualMinutes}
onSwitch={() => setStep("complete")}
/>
) : (
<TimerCompleteModalPanel
plannedMinutes={plannedMinutes}
actualMinutes={actualMinutes}
feedbackText={feedbackText}
onComplete={onConfirm}
/>
)}
</Modal.Panel>
</Modal>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,53 @@ import { useEffect, useState } from "react";
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";

import { getGetHomeQueryKey } from "@/api/generated/endpoints/home/home";
import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus";
import {
useChangeStatus,
useStartTimer,
useStopTimer,
} from "@/api/generated/endpoints/timer/timer";
import {
useChangeSubtaskStatus,
useChangeTodoStatus,
useReorderTodo,
} from "@/api/generated/endpoints/todo/todo";
import { reorderTodos } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-order";
import { useActiveTimer } from "@/hooks/use-active-timer";
import { useTimerQueryInvalidation } from "@/hooks/use-timer-query-invalidation";
import { useTimeSidebarStore } from "@/stores/time-sidebar/useTimeSidebarStore";

export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => {
export interface UseHomeTodosByDateOptions {
onNeedStopConfirm: (dateKey: string, todoId: number) => void;
onTimerAlreadyRunning: () => void;
onStopFeedback: (feedbackText: string | undefined) => void;
}

export const useHomeTodosByDate = (
apiDays: HomeViewDay[],
{
onNeedStopConfirm,
onTimerAlreadyRunning,
onStopFeedback,
}: UseHomeTodosByDateOptions,
) => {
const [todosByDate, setTodosByDate] = useState<Record<string, Todo[]>>({});
const openTimerPanel = useTimeSidebarStore((state) => state.openTimerPanel);
const queryClient = useQueryClient();
const { data: activeTimer } = useActiveTimer();
const { mutate: changeTodoStatus } = useChangeTodoStatus();
const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus();
const { mutate: reorderTodo } = useReorderTodo();
const { mutate: stopTimer } = useStopTimer();
const { invalidateHomeView, invalidateTimerState, invalidateTimeBoxes } =
useTimerQueryInvalidation();

const { mutate: startTimer } = useStartTimer({
mutation: { onSuccess: invalidateTimerState },
});
const { mutate: changeStatus } = useChangeStatus({
mutation: { onSuccess: invalidateTimerState },
});

Comment thread
coderabbitai[bot] marked this conversation as resolved.
useEffect(() => {
setTodosByDate(
Expand All @@ -42,43 +73,88 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => {
}));
};

const invalidateHomeView = () => {
queryClient.invalidateQueries({ queryKey: getGetHomeQueryKey() });
const invalidateFocusTodo = () => {
queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() });
};
const invalidateHomeAndFocus = () => {
invalidateHomeView();
invalidateFocusTodo();
};

const handleToggleCompleted = (
dateKey: string,
todoId: number,
completed: boolean,
) => {
if (completed && activeTimer?.todoId === todoId) {
onNeedStopConfirm(dateKey, todoId);
return;
}

const previous = todosByDate[dateKey] ?? [];
updateTodo(dateKey, todoId, (todo) => ({ ...todo, completed }));

changeTodoStatus(
{ todoId, data: { isCompleted: completed, date: dateKey } },
{
onSuccess: invalidateHomeView,
onSuccess: () => {
invalidateHomeAndFocus();
invalidateTimeBoxes();
},
onError: () => {
setTodosByDate((prev) => ({ ...prev, [dateKey]: previous }));
},
},
);
};

const confirmStopAndComplete = (dateKey: string, todoId: number) => {
if (!activeTimer) return;

stopTimer(
{ timerId: activeTimer.timerId },
{
onSuccess: (response) => {
onStopFeedback(response.data?.aiFeedback ?? undefined);
invalidateTimerState();

updateTodo(dateKey, todoId, (todo) => ({
...todo,
completed: true,
}));
changeTodoStatus(
{ todoId, data: { isCompleted: true, date: dateKey } },
{ onSuccess: invalidateHomeAndFocus },
);
},
},
);
};

const handleTogglePlay = (dateKey: string, todoId: number) => {
// TODO: API
const willRun =
todosByDate[dateKey]?.find((todo) => todo.todoId === todoId)
?.timerStatus !== "RUNNING";

updateTodo(dateKey, todoId, (todo) => ({
...todo,
timerStatus: todo.timerStatus === "RUNNING" ? "STOPPED" : "RUNNING",
}));

if (willRun) {
openTimerPanel();
}

if (activeTimer && activeTimer.todoId === todoId) {
changeStatus({
timerId: activeTimer.timerId,
data: { action: activeTimer.status === "RUNNING" ? "PAUSE" : "RESUME" },
});
return;
}

// 다른 투두의 타이머가 이미 실행/일시정지 중이면 새 타이머를 시작할 수 없다(409)
if (activeTimer) {
onTimerAlreadyRunning();
return;
}

startTimer({ todoId });
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleToggleSubtaskCompleted = (
Expand All @@ -98,7 +174,10 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => {
changeSubtaskStatus(
{ todoId, subtaskId, data: { isCompleted: completed } },
{
onSuccess: invalidateHomeView,
onSuccess: () => {
invalidateHomeAndFocus();
invalidateTimeBoxes();
},
onError: () => {
setTodosByDate((prev) => ({ ...prev, [dateKey]: previous }));
},
Expand Down Expand Up @@ -131,7 +210,7 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => {
reorderTodo(
{ todoId: movedTodo.todoId, data: { newIndex: toIndex, date: dateKey } },
{
onSuccess: invalidateHomeView,
onSuccess: invalidateHomeAndFocus,
onError: () => {
setTodosByDate((prev) => ({ ...prev, [dateKey]: previous }));
},
Expand All @@ -141,10 +220,12 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => {

return {
todosByDate,
activeTimer,
handleToggleCompleted,
handleTogglePlay,
handleToggleSubtaskCompleted,
handleDeleteTodo,
handleReorderTodo,
confirmStopAndComplete,
};
};
Loading
Loading