From e98c0da9519ae409376421b51bf6ff8dc202e1e3 Mon Sep 17 00:00:00 2001 From: kimminna Date: Mon, 13 Jul 2026 02:52:30 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat(web):=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=A1=B0=ED=9A=8C=20API=20=EC=97=B0=EB=8F=99=20(#1?= =?UTF-8?q?59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useSuspenseQuery 기반 useHomeView 훅을 추가하고 mock 대신 실제 API 응답을 사용하도록 홈 화면 컨테이너를 교체했습니다 - Todo 로컬 zod 스키마를 실제 백엔드 응답 스펙에 맞춰 optional 필드를 반영하고 timerStatus에 PAUSED를 추가했습니다 - optional해진 tag 필드에 맞춰 컨테이너 렌더링과 TODO 생성 시 icon null 처리를 수정했습니다 --- .../home/_containers/HomeTodoContainer.tsx | 13 ++++++------- .../todo-modal/use-create-todo-submit.ts | 2 +- .../(with-time-sidebar)/home/_queries/.gitkeep | 0 .../home/_queries/use-home-view.ts | 18 ++++++++++++++++++ .../home/_types/todo-type.ts | 12 ++++++------ 5 files changed, 31 insertions(+), 14 deletions(-) delete mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/.gitkeep create mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view.ts diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx index 704e5abe..82ba8caf 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx @@ -11,7 +11,7 @@ import { HomeDayHeaderContainer } from "@/app/[locale]/(main)/(with-time-sidebar import { 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 { getHomeViewMock } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/home-view-mock"; +import { useHomeView } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view"; import { reorderDaysTodayFirst } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/home-view"; import { DndSortableListProvider } from "@/providers/dnd/DndSortableListProvider"; import { formatDateKey } from "@/utils/date"; @@ -38,10 +38,8 @@ export const HomeTodoContainer = () => { const filter: HomeViewFilter = isWeekView ? "WEEK" : "DEFAULT"; const baseDate = formatDateKey(referenceDate); - const apiDays = useMemo( - () => getHomeViewMock({ filter, baseDate }).days, - [filter, baseDate], - ); + const { data: homeViewData } = useHomeView({ filter, baseDate }); + const apiDays = homeViewData.days; const days = useMemo( () => (isWeekView ? apiDays : reorderDaysTodayFirst(apiDays)), @@ -114,9 +112,10 @@ export const HomeTodoContainer = () => { durationSeconds={todo.durationSeconds} priority={todo.priority} tagName={ - isTagLabelKey(todo.tag.name) + todo.tag && + (isTagLabelKey(todo.tag.name) ? tCommon(`tag.${todo.tag.name}`) - : todo.tag.name + : todo.tag.name) } hasMemo={todo.hasMemo} isRepeated={todo.isRepeated} diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-create-todo-submit.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-create-todo-submit.ts index 4a3e0588..54aca1da 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-create-todo-submit.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-create-todo-submit.ts @@ -10,7 +10,7 @@ const buildTodoFromRequest = ( tag: CreateTodoTag, ): Todo => ({ todoId: Date.now(), - icon: data.icon, + icon: data.icon ?? undefined, title: data.title, completed: false, durationSeconds: convertApiDurationToSeconds(data.duration), diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/.gitkeep b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view.ts new file mode 100644 index 00000000..5ddce1b4 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useSuspenseQuery } from "@tanstack/react-query"; + +import type { GetHomeViewParams } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type"; + +import { + getGetHomeQueryKey, + getHome, +} from "@/api/generated/endpoints/home/home"; +import { homeViewDataSchema } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type"; + +export const useHomeView = ({ filter, baseDate }: GetHomeViewParams) => + useSuspenseQuery({ + queryKey: getGetHomeQueryKey({ filter, baseDate }), + queryFn: ({ signal }) => getHome({ filter, baseDate }, undefined, signal), + select: ({ data }) => homeViewDataSchema.parse(data), + }); diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts index ec5f05d0..42421771 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts @@ -6,7 +6,7 @@ export const todoPrioritySchema = z.enum([ "MEDIUM", "LOW", ]); -export const todoTimerStatusSchema = z.enum(["RUNNING", "STOPPED"]); +export const todoTimerStatusSchema = z.enum(["RUNNING", "STOPPED", "PAUSED"]); export const todoTagSchema = z.object({ tagId: z.number(), @@ -21,16 +21,16 @@ export const todoSubtaskSchema = z.object({ export const todoSchema = z.object({ todoId: z.number(), - icon: z.string().nullable(), + icon: z.string().optional(), title: z.string(), completed: z.boolean(), - durationSeconds: z.number(), - priority: todoPrioritySchema, - tag: todoTagSchema, + durationSeconds: z.number().default(0), + priority: todoPrioritySchema.default("MEDIUM"), + tag: todoTagSchema.optional(), hasMemo: z.boolean(), isRepeated: z.boolean(), timerStatus: todoTimerStatusSchema, - sortOrder: z.number(), + sortOrder: z.number().default(0), subtasks: z.array(todoSubtaskSchema), }); From 5f87fd803a238a372ea9a30648e5529821ad7ac0 Mon Sep 17 00:00:00 2001 From: kimminna Date: Mon, 13 Jul 2026 03:51:12 +0900 Subject: [PATCH 02/12] =?UTF-8?q?fix(web):=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=88=AC=EB=91=90=20=EC=B9=B4=EB=93=9C=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EB=B2=94=EC=9C=84=20=EB=B0=8F=20=EC=98=A4=EB=8A=98?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=9C=84=EC=B9=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 오늘을 배열 맨 앞으로 재정렬하던 reorderDaysTodayFirst를 제거해 과거 7일~미래 7일이 정상 순서로 보이도록 했습니다 - 오늘 카드로 스크롤하는 로직을 scrollIntoView(inline: "start") 기반으로 바꿔 오늘이 정확히 좌측 기준으로 오도록 했습니다 --- .../home/_containers/HomeTodoContainer.tsx | 20 ++++++------- .../home/_hooks/use-home-today-scroll.ts | 29 ++++++++++++++++++- .../home/_utils/home-view.ts | 19 ------------ 3 files changed, 37 insertions(+), 31 deletions(-) delete mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/home-view.ts diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx index 82ba8caf..c7ca64a5 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx @@ -2,17 +2,19 @@ import { cn } from "@repo/timo-design-system/utils"; import { useTranslations } from "next-intl"; -import { useEffect, useMemo } from "react"; +import { useEffect } 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 { useHomeTodayScrollRef } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-today-scroll"; +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 { reorderDaysTodayFirst } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/home-view"; import { DndSortableListProvider } from "@/providers/dnd/DndSortableListProvider"; import { formatDateKey } from "@/utils/date"; @@ -39,12 +41,7 @@ export const HomeTodoContainer = () => { const baseDate = formatDateKey(referenceDate); const { data: homeViewData } = useHomeView({ filter, baseDate }); - const apiDays = homeViewData.days; - - const days = useMemo( - () => (isWeekView ? apiDays : reorderDaysTodayFirst(apiDays)), - [isWeekView, apiDays], - ); + const days = homeViewData.days; const { todosByDate, @@ -53,10 +50,10 @@ export const HomeTodoContainer = () => { handleTogglePlay, handleToggleSubtaskCompleted, handleReorderTodo, - } = useHomeTodosByDate(apiDays); + } = useHomeTodosByDate(days); useEffect(() => { - scrollRef.current?.scrollTo({ left: 0 }); + scrollContainerToToday(scrollRef.current); }, [isWeekView, scrollRef]); return ( @@ -75,6 +72,7 @@ export const HomeTodoContainer = () => { return (
void; const listeners = new Set(); +const TODAY_CARD_SELECTOR = '[data-today="true"]'; + +/** + * days 배열이 과거→오늘→미래 순서를 유지하므로, 오늘 카드를 배열 맨 앞으로 + * 옮기는 대신 실제 오늘 카드의 위치로 스크롤해 오늘이 좌측 기준으로 보이게 한다. + * scrollIntoView의 inline:"start"는 조상 요소의 margin/position이나 수동 + * 좌표 계산과 무관하게 브라우저가 직접 정렬 위치를 계산하므로 더 안전하다. + */ +export const scrollContainerToToday = ( + container: HTMLElement | null, + behavior: ScrollBehavior = "auto", +): void => { + if (!container) { + return; + } + + const todayElement = + container.querySelector(TODAY_CARD_SELECTOR); + + if (!todayElement) { + container.scrollTo({ left: 0, behavior }); + return; + } + + todayElement.scrollIntoView({ behavior, inline: "start", block: "nearest" }); +}; + export const triggerScrollToToday = (): void => { listeners.forEach((listener) => listener()); }; @@ -17,7 +44,7 @@ export const useHomeTodayScrollRef = < useEffect(() => { const listener: ScrollToTodayListener = () => { - containerRef.current?.scrollTo({ left: 0, behavior: "smooth" }); + scrollContainerToToday(containerRef.current, "smooth"); }; listeners.add(listener); diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/home-view.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/home-view.ts deleted file mode 100644 index ec557568..00000000 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/home-view.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { HomeViewDay } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type"; - -/** - * 기본 뷰(가로 스크롤)에서 오늘 날짜가 맨 앞에 오도록 days 배열을 회전시킨다. - * 오늘 이전의 날짜들은 순서를 유지한 채 배열 끝으로 옮겨진다. - * @param days - 재정렬할 날짜 목록 - * @returns 오늘부터 시작하도록 재정렬된 날짜 목록 (오늘이 없으면 원본 그대로 반환) - */ -export const reorderDaysTodayFirst = (days: HomeViewDay[]): HomeViewDay[] => { - const todayIndex = days.findIndex((day) => day.isToday); - - if (todayIndex === -1) { - return days; - } - - const upcoming = days.slice(todayIndex); - const past = days.slice(0, todayIndex); - return [...upcoming, ...past]; -}; From f2a61091e4c677af6a73eeab5fcf2820c1920098 Mon Sep 17 00:00:00 2001 From: kimminna Date: Mon, 13 Jul 2026 04:13:07 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat(web):=20TODO=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?API=20=EC=97=B0=EB=8F=99=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mock으로 로컬에서 TODO를 생성하던 로직을 실제 POST /api/v1/todos 뮤테이션 호출로 교체했습니다 - 응답을 로컬 zod 스키마로 검증해 실제 서버 todoId를 사용하도록 했습니다 - 생성 실패 시 에러 토스트를 노출하도록 했습니다 --- .../todo-modal/CreateTodoModalContainer.tsx | 17 +++++- .../todo-modal/use-create-todo-submit.ts | 57 +++++++++++++++++-- .../home/_types/todo-type.ts | 5 ++ apps/timo-web/messages/en.json | 3 +- apps/timo-web/messages/ko.json | 3 +- 5 files changed, 76 insertions(+), 9 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContainer.tsx index 6b461520..a0577a96 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContainer.tsx @@ -8,6 +8,7 @@ import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types import { CreateTodoModalContent } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContent"; import { useCreateTodoSubmit } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-create-todo-submit"; +import { AnimatedToast } from "@/components/toast/AnimatedToast"; export interface CreateTodoModalContainerProps { defaultDate?: Date; @@ -19,7 +20,9 @@ export const CreateTodoModalContainer = ({ onCreate, }: CreateTodoModalContainerProps) => { const t = useTranslations("Home"); - const { handleSubmit } = useCreateTodoSubmit({ onCreate }); + const tToast = useTranslations("Toast"); + const { handleSubmit, isErrorToastOpen, closeErrorToast } = + useCreateTodoSubmit({ onCreate }); const handleAddClick = () => { overlay.open(({ isOpen, close, unmount }) => ( @@ -33,5 +36,15 @@ export const CreateTodoModalContainer = ({ )); }; - return ; + return ( + <> + + + + + ); }; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-create-todo-submit.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-create-todo-submit.ts index 54aca1da..bb0bdc42 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-create-todo-submit.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-create-todo-submit.ts @@ -1,15 +1,38 @@ +"use client"; + +import { useState } from "react"; + +import type { TodoCreateRequest } from "@/api/generated/models"; import type { CreateTodoRequest } from "@/api/todo/todo-schema"; import type { CreateTodoTag } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContent"; import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; +import { useCreateTodo } from "@/api/generated/endpoints/todo/todo"; +import { todoCreateResponseSchema } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; import { convertApiDurationToSeconds } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-time"; -// 목데이터: 실제 API 호출 없이 로컬 상태에만 즉시 반영한다. -const buildTodoFromRequest = ( +const buildCreateTodoRequestBody = ( + data: CreateTodoRequest, +): TodoCreateRequest => ({ + icon: data.icon ?? undefined, + title: data.title, + subtasks: data.subtasks?.length ? data.subtasks : undefined, + date: data.date, + duration: data.duration, + priority: data.priority ?? undefined, + tagId: data.tagId ?? undefined, + repeatType: data.repeatType, + repeatWeekdays: data.repeatWeekdays?.length ? data.repeatWeekdays : undefined, + repeatDayOfMonth: data.repeatDayOfMonth ?? undefined, + memo: data.memo?.trim() ? data.memo : undefined, +}); + +const buildTodoFromCreated = ( + todoId: number, data: CreateTodoRequest, tag: CreateTodoTag, ): Todo => ({ - todoId: Date.now(), + todoId, icon: data.icon ?? undefined, title: data.title, completed: false, @@ -34,9 +57,33 @@ export interface UseCreateTodoSubmitParams { export const useCreateTodoSubmit = ({ onCreate, }: UseCreateTodoSubmitParams) => { + const [isErrorToastOpen, setIsErrorToastOpen] = useState(false); + const { mutate: createTodo } = useCreateTodo(); + const handleSubmit = (data: CreateTodoRequest, tag: CreateTodoTag) => { - onCreate(buildTodoFromRequest(data, tag)); + createTodo( + { data: buildCreateTodoRequestBody(data) }, + { + onSuccess: (response) => { + const parsed = todoCreateResponseSchema.safeParse(response.data); + + if (!parsed.success) { + setIsErrorToastOpen(true); + return; + } + + onCreate(buildTodoFromCreated(parsed.data.todoId, data, tag)); + }, + onError: () => { + setIsErrorToastOpen(true); + }, + }, + ); }; - return { handleSubmit }; + return { + handleSubmit, + isErrorToastOpen, + closeErrorToast: () => setIsErrorToastOpen(false), + }; }; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts index 42421771..a97d6665 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts @@ -34,8 +34,13 @@ export const todoSchema = z.object({ subtasks: z.array(todoSubtaskSchema), }); +export const todoCreateResponseSchema = z.object({ + todoId: z.number(), +}); + export type TodoPriorityTypes = z.infer; export type TodoTimerStatusTypes = z.infer; export type TodoTag = z.infer; export type TodoSubtask = z.infer; export type Todo = z.infer; +export type TodoCreateResponseData = z.infer; diff --git a/apps/timo-web/messages/en.json b/apps/timo-web/messages/en.json index 2447a07d..8d388a39 100644 --- a/apps/timo-web/messages/en.json +++ b/apps/timo-web/messages/en.json @@ -110,7 +110,8 @@ "tagLimit": "You can create up to 8 tags, and to add more, please delete existing tags.", "todoLimitLine1": "You can add up to 20 incomplete to-dos.", "todoLimitLine2": "To add a new to-do, please complete an existing one.", - "onboardingSubmitFailed": "Failed to save onboarding. Please try again." + "onboardingSubmitFailed": "Failed to save onboarding. Please try again.", + "todoCreateFailed": "Failed to create the to-do. Please try again." }, "Login": { "animationLabel": "Login animation", diff --git a/apps/timo-web/messages/ko.json b/apps/timo-web/messages/ko.json index 384eaebf..738e96f9 100644 --- a/apps/timo-web/messages/ko.json +++ b/apps/timo-web/messages/ko.json @@ -110,7 +110,8 @@ "tagLimit": "태그는 최대 8개까지 만들 수 있으며, 추가하려면 기존 태그를 삭제해 주세요.", "todoLimitLine1": "완료되지 않은 투두는 최대 20개까지 추가할 수 있어요.", "todoLimitLine2": "새로운 투두를 추가하려면 기존 투두를 완료해주세요.", - "onboardingSubmitFailed": "온보딩 저장에 실패했어요. 다시 시도해 주세요." + "onboardingSubmitFailed": "온보딩 저장에 실패했어요. 다시 시도해 주세요.", + "todoCreateFailed": "투두 생성에 실패했어요. 다시 시도해 주세요." }, "Login": { "animationLabel": "로그인 애니메이션", From e6fcd5d7aba88c05433205dfd07421f19a53025a Mon Sep 17 00:00:00 2001 From: kimminna Date: Mon, 13 Jul 2026 16:09:16 +0900 Subject: [PATCH 04/12] =?UTF-8?q?fix(web):=20=ED=99=88=20=ED=88=AC?= =?UTF-8?q?=EB=91=90=20=EC=9D=91=EB=8B=B5=EC=9D=98=20null=20priority=C2=B7?= =?UTF-8?q?tag=20=ED=8C=8C=EC=8B=B1=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드가 priority/tag를 값이 없을 때 필드 생략이 아닌 명시적 null로 내려줘서 zod parse가 깨지던 문제를 수정했습니다 - .optional()을 .nullish().transform(...)으로 바꿔 null도 허용하고 각각 "MEDIUM"/undefined로 정규화했습니다 --- .../(main)/(with-time-sidebar)/home/_types/todo-type.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts index a97d6665..58dd54ab 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts @@ -25,8 +25,8 @@ export const todoSchema = z.object({ title: z.string(), completed: z.boolean(), durationSeconds: z.number().default(0), - priority: todoPrioritySchema.default("MEDIUM"), - tag: todoTagSchema.optional(), + priority: todoPrioritySchema.nullish().transform((v) => v ?? "MEDIUM"), + tag: todoTagSchema.nullish().transform((v) => v ?? undefined), hasMemo: z.boolean(), isRepeated: z.boolean(), timerStatus: todoTimerStatusSchema, From 92ba234e96f6b976d84eaa7fa31c86c58f87f6d1 Mon Sep 17 00:00:00 2001 From: kimminna Date: Mon, 13 Jul 2026 17:51:12 +0900 Subject: [PATCH 05/12] =?UTF-8?q?fix(web):=20=ED=99=88=20=ED=88=AC?= =?UTF-8?q?=EB=91=90=20=EC=9D=91=EB=8B=B5=20icon=20null=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드가 icon 값이 없을 때 필드 생략이 아닌 명시적 null로 내려줘서 zod parse가 깨지던 문제를 수정했습니다 - icon 필드를 .optional()에서 .nullish().transform(...)으로 바꿔 null도 허용하고 undefined로 정규화했습니다 --- .../(main)/(with-time-sidebar)/home/_types/todo-type.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts index 58dd54ab..6f79f92f 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type.ts @@ -21,7 +21,10 @@ export const todoSubtaskSchema = z.object({ export const todoSchema = z.object({ todoId: z.number(), - icon: z.string().optional(), + icon: z + .string() + .nullish() + .transform((v) => v ?? undefined), title: z.string(), completed: z.boolean(), durationSeconds: z.number().default(0), From 0bf7ac1c29902d76369b10a2206439cf0c9a04ab Mon Sep 17 00:00:00 2001 From: kimminna Date: Mon, 13 Jul 2026 17:51:55 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat(web):=20=ED=88=AC=EB=91=90=20?= =?UTF-8?q?=EB=B0=8F=20=ED=95=98=EC=9C=84=20=ED=88=AC=EB=91=90=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 투두/하위 투두 체크박스 토글 시 실제 완료 상태 변경 API를 호출하도록 연동했습니다 - 낙관적 업데이트로 즉시 UI에 반영하고, 실패 시 이전 상태로 롤백하도록 했습니다 - 성공 시에는 응답 필드를 직접 반영하는 대신 홈 뷰 쿼리를 무효화해 서버 최신 데이터로 다시 조회하도록 했습니다 --- .../home/_hooks/use-home-todos-by-date.ts | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts index a783cb1e..50fbff35 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts @@ -1,10 +1,16 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; 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 { + useChangeSubtaskStatus, + useChangeTodoStatus, +} from "@/api/generated/endpoints/todo/todo"; import { patchTodoOrderMock } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-order-mock"; import { reorderTodos } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-order"; import { useTimeSidebarStore } from "@/stores/time-sidebar/useTimeSidebarStore"; @@ -12,6 +18,9 @@ import { useTimeSidebarStore } from "@/stores/time-sidebar/useTimeSidebarStore"; export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { const [todosByDate, setTodosByDate] = useState>({}); const openTimerPanel = useTimeSidebarStore((state) => state.openTimerPanel); + const queryClient = useQueryClient(); + const { mutate: changeTodoStatus } = useChangeTodoStatus(); + const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); useEffect(() => { setTodosByDate( @@ -32,12 +41,8 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { })); }; - const handleAddTodo = (dateKey: string, todo: Todo) => { - // TODO: API - setTodosByDate((prev) => ({ - ...prev, - [dateKey]: [...(prev[dateKey] ?? []), todo], - })); + const invalidateHomeView = () => { + queryClient.invalidateQueries({ queryKey: getGetHomeQueryKey() }); }; const handleToggleCompleted = ( @@ -45,8 +50,18 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { todoId: number, completed: boolean, ) => { - // TODO: API + const previous = todosByDate[dateKey] ?? []; updateTodo(dateKey, todoId, (todo) => ({ ...todo, completed })); + + changeTodoStatus( + { todoId, data: { isCompleted: completed, date: dateKey } }, + { + onSuccess: invalidateHomeView, + onError: () => { + setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); + }, + }, + ); }; const handleTogglePlay = (dateKey: string, todoId: number) => { @@ -71,13 +86,23 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { subtaskId: number, completed: boolean, ) => { - // TODO: API + const previous = todosByDate[dateKey] ?? []; updateTodo(dateKey, todoId, (todo) => ({ ...todo, subtasks: todo.subtasks.map((subtask) => subtask.subtaskId === subtaskId ? { ...subtask, completed } : subtask, ), })); + + changeSubtaskStatus( + { todoId, subtaskId, data: { isCompleted: completed } }, + { + onSuccess: invalidateHomeView, + onError: () => { + setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); + }, + }, + ); }; const handleReorderTodo = async ( @@ -103,7 +128,6 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { return { todosByDate, - handleAddTodo, handleToggleCompleted, handleTogglePlay, handleToggleSubtaskCompleted, From f794401be21161eefb234c70f4f19d2c7327ca89 Mon Sep 17 00:00:00 2001 From: kimminna Date: Mon, 13 Jul 2026 17:52:32 +0900 Subject: [PATCH 07/12] =?UTF-8?q?refactor(web):=20=ED=88=AC=EB=91=90=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=84=B1=EA=B3=B5=20=EC=8B=9C=20=ED=99=88?= =?UTF-8?q?=20=EB=B7=B0=20=EC=BF=BC=EB=A6=AC=20=EB=AC=B4=ED=9A=A8=ED=99=94?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로컬 상태에 Todo 객체를 수동으로 조립해 넣던 방식을 홈 뷰 쿼리 invalidate로 교체해 서버 상태 중복 관리를 없앴습니다 - 더 이상 쓰이지 않는 onCreate/CreateTodoTag 전달 체인을 컨테이너에서 정리했습니다 --- .../home/_containers/HomeTodoContainer.tsx | 2 - .../todo-card/HomeDayHeaderContainer.tsx | 5 +-- .../todo-modal/CreateTodoModalContainer.tsx | 6 +-- .../todo-modal/use-create-todo-submit.ts | 41 +++---------------- 4 files changed, 8 insertions(+), 46 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx index c7ca64a5..6b05153e 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx @@ -45,7 +45,6 @@ export const HomeTodoContainer = () => { const { todosByDate, - handleAddTodo, handleToggleCompleted, handleTogglePlay, handleToggleSubtaskCompleted, @@ -87,7 +86,6 @@ export const HomeTodoContainer = () => { isToday={day.isToday} totalCount={todos.length} completedCount={completedCount} - onCreateTodo={(todo) => handleAddTodo(dateKey, todo)} /> void; } export const HomeDayHeaderContainer = ({ @@ -27,7 +25,6 @@ export const HomeDayHeaderContainer = ({ isToday, totalCount, completedCount, - onCreateTodo, }: HomeDayHeaderContainerProps) => { const tCommon = useTranslations("Common"); const date = parseDateKey(dateKey) ?? getToday(); @@ -42,7 +39,7 @@ export const HomeDayHeaderContainer = ({ totalCount={totalCount} completedCount={completedCount} /> - +
); }; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContainer.tsx index a0577a96..cd14f33d 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContainer.tsx @@ -4,25 +4,21 @@ import { AddTaskButton } from "@repo/timo-design-system/ui"; import { useTranslations } from "next-intl"; import { overlay } from "overlay-kit"; -import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; - import { CreateTodoModalContent } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContent"; import { useCreateTodoSubmit } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-create-todo-submit"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; export interface CreateTodoModalContainerProps { defaultDate?: Date; - onCreate: (todo: Todo) => void; } export const CreateTodoModalContainer = ({ defaultDate, - onCreate, }: CreateTodoModalContainerProps) => { const t = useTranslations("Home"); const tToast = useTranslations("Toast"); const { handleSubmit, isErrorToastOpen, closeErrorToast } = - useCreateTodoSubmit({ onCreate }); + useCreateTodoSubmit(); const handleAddClick = () => { overlay.open(({ isOpen, close, unmount }) => ( diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-create-todo-submit.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-create-todo-submit.ts index bb0bdc42..9c7509df 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-create-todo-submit.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-create-todo-submit.ts @@ -1,15 +1,14 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import type { TodoCreateRequest } from "@/api/generated/models"; import type { CreateTodoRequest } from "@/api/todo/todo-schema"; -import type { CreateTodoTag } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContent"; -import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; +import { getGetHomeQueryKey } from "@/api/generated/endpoints/home/home"; import { useCreateTodo } from "@/api/generated/endpoints/todo/todo"; import { todoCreateResponseSchema } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; -import { convertApiDurationToSeconds } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-time"; const buildCreateTodoRequestBody = ( data: CreateTodoRequest, @@ -27,40 +26,12 @@ const buildCreateTodoRequestBody = ( memo: data.memo?.trim() ? data.memo : undefined, }); -const buildTodoFromCreated = ( - todoId: number, - data: CreateTodoRequest, - tag: CreateTodoTag, -): Todo => ({ - todoId, - icon: data.icon ?? undefined, - title: data.title, - completed: false, - durationSeconds: convertApiDurationToSeconds(data.duration), - priority: data.priority ?? "MEDIUM", - tag: { tagId: tag.id, name: tag.name }, - hasMemo: Boolean(data.memo?.trim()), - isRepeated: data.repeatType !== "NONE", - timerStatus: "STOPPED", - sortOrder: 0, - subtasks: (data.subtasks ?? []).map((content, index) => ({ - subtaskId: Date.now() + index, - content, - completed: false, - })), -}); - -export interface UseCreateTodoSubmitParams { - onCreate: (todo: Todo) => void; -} - -export const useCreateTodoSubmit = ({ - onCreate, -}: UseCreateTodoSubmitParams) => { +export const useCreateTodoSubmit = () => { const [isErrorToastOpen, setIsErrorToastOpen] = useState(false); const { mutate: createTodo } = useCreateTodo(); + const queryClient = useQueryClient(); - const handleSubmit = (data: CreateTodoRequest, tag: CreateTodoTag) => { + const handleSubmit = (data: CreateTodoRequest) => { createTodo( { data: buildCreateTodoRequestBody(data) }, { @@ -72,7 +43,7 @@ export const useCreateTodoSubmit = ({ return; } - onCreate(buildTodoFromCreated(parsed.data.todoId, data, tag)); + queryClient.invalidateQueries({ queryKey: getGetHomeQueryKey() }); }, onError: () => { setIsErrorToastOpen(true); From dd6c46d969af415fc26d263403d973a405ddc895 Mon Sep 17 00:00:00 2001 From: kimminna Date: Mon, 13 Jul 2026 17:53:29 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat(web):=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20?= =?UTF-8?q?AI=20=EC=86=8C=EC=9A=94=20=EC=8B=9C=EA=B0=84=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=B6=94=EC=B2=9C=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 시간 선택 드롭다운을 열면 현재 제목/태그 기준으로 AI 예상 소요 시간 추천 API를 호출하도록 연동했습니다 - 추천 결과를 duration 입력값에 자동 반영하고, 실패 시 토스트로 안내합니다 - duration 수동 입력은 숫자와 콜론만 허용하도록 마스킹을 추가했습니다 (시 자릿수는 제한 없음, 분은 2자리) - Dropdown 컴포넌트에 열림 이벤트(onOpenChange)를 추가해 TimeSelector/TodoToolbar를 통해 상위로 전달했습니다 --- apps/timo-web/api/todo/todo-schema.ts | 7 ++ .../todo-modal/CreateTodoModalContent.tsx | 25 ++--- .../home/_hooks/todo-modal/use-time-field.ts | 100 ++++++++++++++++-- apps/timo-web/messages/en.json | 3 +- apps/timo-web/messages/ko.json | 3 +- .../components/layout/dropdown/Dropdown.tsx | 9 +- .../time/time-selector/TimeSelector.tsx | 9 +- .../todo/todo-toolbar/TodoToolbar.tsx | 3 + 8 files changed, 133 insertions(+), 26 deletions(-) diff --git a/apps/timo-web/api/todo/todo-schema.ts b/apps/timo-web/api/todo/todo-schema.ts index ec98b4e4..a39335fe 100644 --- a/apps/timo-web/api/todo/todo-schema.ts +++ b/apps/timo-web/api/todo/todo-schema.ts @@ -65,7 +65,14 @@ export const createTodoRequestSchema = z } }); +export const recommendDurationResponseSchema = z.object({ + recommendedMinutes: z.number(), +}); + export type CreateTodoRequest = z.infer; +export type RecommendDurationResponseData = z.infer< + typeof recommendDurationResponseSchema +>; export type TodoIcon = z.infer; export type TodoPriority = z.infer; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContent.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContent.tsx index 9489bcb3..a128e213 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContent.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContent.tsx @@ -16,27 +16,19 @@ import { CreateTodoTaskFields } from "@/app/[locale]/(main)/(with-time-sidebar)/ import { useIconField } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-icon-field"; import { useRepeatField } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-repeat-field"; import { useSubtaskField } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-subtask-field"; -import { - DEFAULT_TAG, - useTagField, -} from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-tag-field"; +import { useTagField } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-tag-field"; import { useTimeField } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-time-field"; import { useTitleField } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-title-field"; import { formatDateToIsoDate } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date"; import { OverlayModal } from "@/components/modal/OverlayModal"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; -export interface CreateTodoTag { - id: number; - name: string; -} - const createDefaultValues = (defaultDate?: Date): CreateTodoRequest => ({ icon: null, title: "", subtasks: [], date: formatDateToIsoDate(defaultDate ?? new Date()), - duration: "0:00", + duration: "00:00", priority: null, tagId: null, repeatType: "NONE", @@ -50,7 +42,7 @@ export interface CreateTodoModalContentProps { onClose: () => void; onExited: () => void; defaultDate?: Date; - onSubmit: (data: CreateTodoRequest, tag: CreateTodoTag) => void; + onSubmit: (data: CreateTodoRequest) => void; } export const CreateTodoModalContent = ({ @@ -88,9 +80,7 @@ export const CreateTodoModalContent = ({ : undefined; const handleFormSubmit = (data: CreateTodoRequest) => { - const tag = tagField.selectedTagOption ?? DEFAULT_TAG; - - onSubmit(data, { id: data.tagId ?? tag.id, name: tag.name }); + onSubmit(data); reset(createDefaultValues(defaultDate)); subtaskField.reset(); @@ -169,6 +159,7 @@ export const CreateTodoModalContent = ({ onTimeChange={timeField.handleDurationInputChange} selectedTime={timeField.selectedTime} onSelectTime={timeField.handleSelectTime} + onTimeOpen={timeField.handleTimeSelectorOpen} priority={priorityField.value ?? undefined} onSelectPriority={(level: PriorityLevel) => priorityField.onChange(level) @@ -228,6 +219,12 @@ export const CreateTodoModalContent = ({

} /> + + ); }; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-time-field.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-time-field.ts index 6243673f..cc0756d6 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-time-field.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-time-field.ts @@ -1,10 +1,13 @@ import { useState } from "react"; -import { useController } from "react-hook-form"; +import { useController, useWatch } from "react-hook-form"; import type { CreateTodoRequest } from "@/api/todo/todo-schema"; import type { TimeSelection } from "@repo/timo-design-system/ui"; import type { Control } from "react-hook-form"; +import { useRecommendDuration } from "@/api/generated/endpoints/ai/ai"; +import { recommendDurationResponseSchema } from "@/api/todo/todo-schema"; + const TIME_OPTIONS = [ { minute: 15, value: "15", unit: "min" }, { minute: 30, value: "30", unit: "min" }, @@ -13,19 +16,97 @@ const TIME_OPTIONS = [ { minute: 90, value: "1.5", unit: "h" }, ]; +const MINUTES_PER_HOUR = 60; + export interface UseTimeFieldParams { control: Control; } +/** + * 숫자와 콜론만 허용한다. 콜론은 첫 번째 것만 유지하고(그 뒤 콜론은 제거), + * 시(hour) 자릿수는 제한하지 않는다. 분(minute)만 2자리로 자른다. + */ +const formatDurationInput = (raw: string): string => { + const sanitized = raw.replace(/[^\d:]/g, ""); + const colonIndex = sanitized.indexOf(":"); + + if (colonIndex === -1) { + return sanitized; + } + + const hours = sanitized.slice(0, colonIndex).replace(/:/g, ""); + const minutes = sanitized + .slice(colonIndex + 1) + .replace(/:/g, "") + .slice(0, 2); + + return `${hours}:${minutes}`; +}; + +const formatMinutesToDuration = (totalMinutes: number): string => { + const hours = Math.floor(totalMinutes / MINUTES_PER_HOUR); + const minutes = totalMinutes % MINUTES_PER_HOUR; + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; +}; + export const useTimeField = ({ control }: UseTimeFieldParams) => { const { field } = useController({ name: "duration", control }); + const title = useWatch({ control, name: "title" }); + const tagId = useWatch({ control, name: "tagId" }); const [selectedTime, setSelectedTime] = useState(); - const [timeDisplay, setTimeDisplay] = useState("0:00"); + const [timeDisplay, setTimeDisplay] = useState("00:00"); + const [recommendedDuration, setRecommendedDuration] = useState(); + const [isAiDurationErrorToastOpen, setIsAiDurationErrorToastOpen] = + useState(false); + const { mutate: recommendDuration, isPending: isRecommendingDuration } = + useRecommendDuration(); + + const applyRecommendedDuration = (duration: string) => { + setRecommendedDuration(duration); + setSelectedTime("ai"); + setTimeDisplay(duration); + field.onChange(duration); + }; + + const handleTimeSelectorOpen = () => { + const trimmedTitle = title.trim(); + if (!trimmedTitle || isRecommendingDuration) return; + + recommendDuration( + { data: { title: trimmedTitle, tagId: tagId ?? undefined } }, + { + onSuccess: (response) => { + const parsed = recommendDurationResponseSchema.safeParse( + response.data, + ); + + if (!parsed.success) { + setIsAiDurationErrorToastOpen(true); + return; + } + + applyRecommendedDuration( + formatMinutesToDuration(parsed.data.recommendedMinutes), + ); + }, + onError: () => { + setIsAiDurationErrorToastOpen(true); + }, + }, + ); + }; const handleSelectTime = (value: TimeSelection) => { - setSelectedTime(value); + if (value === "ai") { + setSelectedTime(value); + if (recommendedDuration) { + setTimeDisplay(recommendedDuration); + field.onChange(recommendedDuration); + } + return; + } - if (value === "ai") return; + setSelectedTime(value); const option = TIME_OPTIONS.find((item) => item.minute === value); if (!option) return; @@ -36,13 +117,15 @@ export const useTimeField = ({ control }: UseTimeFieldParams) => { const handleDurationInputChange = (value: string) => { setSelectedTime(undefined); - setTimeDisplay(value); - field.onChange(value); + const formatted = formatDurationInput(value); + setTimeDisplay(formatted); + field.onChange(formatted); }; const resetTime = () => { setSelectedTime(undefined); - setTimeDisplay("0:00"); + setTimeDisplay("00:00"); + setRecommendedDuration(undefined); }; return { @@ -52,6 +135,9 @@ export const useTimeField = ({ control }: UseTimeFieldParams) => { timeDisplay, handleSelectTime, handleDurationInputChange, + handleTimeSelectorOpen, resetTime, + isAiDurationErrorToastOpen, + closeAiDurationErrorToast: () => setIsAiDurationErrorToastOpen(false), }; }; diff --git a/apps/timo-web/messages/en.json b/apps/timo-web/messages/en.json index 8d388a39..4b2ff9cd 100644 --- a/apps/timo-web/messages/en.json +++ b/apps/timo-web/messages/en.json @@ -111,7 +111,8 @@ "todoLimitLine1": "You can add up to 20 incomplete to-dos.", "todoLimitLine2": "To add a new to-do, please complete an existing one.", "onboardingSubmitFailed": "Failed to save onboarding. Please try again.", - "todoCreateFailed": "Failed to create the to-do. Please try again." + "todoCreateFailed": "Failed to create the to-do. Please try again.", + "aiDurationRecommendFailed": "Failed to get the AI duration recommendation. Please try again." }, "Login": { "animationLabel": "Login animation", diff --git a/apps/timo-web/messages/ko.json b/apps/timo-web/messages/ko.json index 738e96f9..19906979 100644 --- a/apps/timo-web/messages/ko.json +++ b/apps/timo-web/messages/ko.json @@ -111,7 +111,8 @@ "todoLimitLine1": "완료되지 않은 투두는 최대 20개까지 추가할 수 있어요.", "todoLimitLine2": "새로운 투두를 추가하려면 기존 투두를 완료해주세요.", "onboardingSubmitFailed": "온보딩 저장에 실패했어요. 다시 시도해 주세요.", - "todoCreateFailed": "투두 생성에 실패했어요. 다시 시도해 주세요." + "todoCreateFailed": "투두 생성에 실패했어요. 다시 시도해 주세요.", + "aiDurationRecommendFailed": "AI 추천 소요 시간을 가져오지 못했어요. 다시 시도해 주세요." }, "Login": { "animationLabel": "로그인 애니메이션", diff --git a/packages/timo-design-system/src/components/layout/dropdown/Dropdown.tsx b/packages/timo-design-system/src/components/layout/dropdown/Dropdown.tsx index 9fd77030..6262eba1 100644 --- a/packages/timo-design-system/src/components/layout/dropdown/Dropdown.tsx +++ b/packages/timo-design-system/src/components/layout/dropdown/Dropdown.tsx @@ -42,9 +42,10 @@ const useDropdownContext = (): DropdownContextValue => { export interface DropdownProps { children: ReactNode; className?: string; + onOpenChange?: (isOpen: boolean) => void; } -const DropdownRoot = ({ children, className }: DropdownProps) => { +const DropdownRoot = ({ children, className, onOpenChange }: DropdownProps) => { const [isOpen, setIsOpen] = useState(false); const rootRef = useRef(null); const triggerRef = useRef(null); @@ -77,7 +78,11 @@ const DropdownRoot = ({ children, className }: DropdownProps) => { }; }, [isOpen]); - const toggle = () => setIsOpen((prev) => !prev); + const toggle = () => { + const next = !isOpen; + setIsOpen(next); + onOpenChange?.(next); + }; const close = () => setIsOpen(false); return ( diff --git a/packages/timo-design-system/src/components/time/time-selector/TimeSelector.tsx b/packages/timo-design-system/src/components/time/time-selector/TimeSelector.tsx index 9a524f47..11c28a31 100644 --- a/packages/timo-design-system/src/components/time/time-selector/TimeSelector.tsx +++ b/packages/timo-design-system/src/components/time/time-selector/TimeSelector.tsx @@ -19,6 +19,7 @@ export interface TimeSelectorProps { times: TimeOption[]; selected?: TimeSelection; onSelect?: (value: TimeSelection) => void; + onOpen?: () => void; } export const TimeSelector = ({ @@ -28,11 +29,17 @@ export const TimeSelector = ({ times, selected, onSelect, + onOpen, }: TimeSelectorProps) => { const isAiSelected = selected === "ai"; return ( - + { + if (isOpen) onOpen?.(); + }} + > {trigger} diff --git a/packages/timo-design-system/src/components/todo/todo-toolbar/TodoToolbar.tsx b/packages/timo-design-system/src/components/todo/todo-toolbar/TodoToolbar.tsx index 310f8054..d77da1ef 100644 --- a/packages/timo-design-system/src/components/todo/todo-toolbar/TodoToolbar.tsx +++ b/packages/timo-design-system/src/components/todo/todo-toolbar/TodoToolbar.tsx @@ -38,6 +38,7 @@ export interface TodoToolbarProps { onTimeChange?: (time: string) => void; selectedTime?: TimeSelection; onSelectTime?: (value: TimeSelection) => void; + onTimeOpen?: () => void; priority?: PriorityLevel; onSelectPriority?: (priority: PriorityLevel) => void; @@ -66,6 +67,7 @@ export const TodoToolbar = ({ onTimeChange, selectedTime, onSelectTime, + onTimeOpen, priority, onSelectPriority, tagLabel, @@ -117,6 +119,7 @@ export const TodoToolbar = ({ times={timeOptions} selected={selectedTime} onSelect={onSelectTime} + onOpen={onTimeOpen} /> Date: Mon, 13 Jul 2026 17:54:43 +0900 Subject: [PATCH 09/12] =?UTF-8?q?fix(web):=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EB=B0=94=EA=B9=A5=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=EA=B9=8C=EC=A7=80=20=EB=8B=AB=ED=9E=88?= =?UTF-8?q?=EB=8A=94=20=EB=A0=88=EC=9D=B4=EC=8A=A4=20=EC=BB=A8=EB=94=94?= =?UTF-8?q?=EC=85=98=20=EC=88=98=EC=A0=95=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 드롭다운의 바깥 클릭 감지(mousedown)가 모달의 배경 클릭 닫기 판단(click)보다 먼저 실행되면서, 플로팅 레이어가 이미 닫힌 상태로 판단돼 모달까지 함께 닫히던 문제를 수정했습니다 - 배경 클릭 시작 시점(mousedown 캡처 단계)에 플로팅 레이어 열림 여부를 미리 스냅샷해두고, 이후 click 시점에는 그 값을 사용하도록 했습니다 --- apps/timo-web/components/modal/OverlayModal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/timo-web/components/modal/OverlayModal.tsx b/apps/timo-web/components/modal/OverlayModal.tsx index 4cc6d292..ce22ecc2 100644 --- a/apps/timo-web/components/modal/OverlayModal.tsx +++ b/apps/timo-web/components/modal/OverlayModal.tsx @@ -29,6 +29,7 @@ export const OverlayModal = ({ const [shouldRender, setShouldRender] = useState(isOpen); const [isVisible, setIsVisible] = useState(false); const dialogRef = useRef(null); + const wasFloatingLayerOpenRef = useRef(false); useEffect(() => { if (!isOpen) { @@ -77,8 +78,11 @@ export const OverlayModal = ({ "bg-timo-overlay fixed inset-0 z-40 transition-opacity duration-200 ease-out", isVisible ? "opacity-100" : "opacity-0", )} + onMouseDownCapture={() => { + wasFloatingLayerOpenRef.current = hasOpenFloatingLayer(); + }} onClick={() => { - if (hasOpenFloatingLayer()) return; + if (wasFloatingLayerOpenRef.current) return; onClose(); }} aria-hidden="true" From 001b5746aabb76788719df0f5bd0cd82f319bc35 Mon Sep 17 00:00:00 2001 From: kimminna Date: Mon, 13 Jul 2026 17:55:17 +0900 Subject: [PATCH 10/12] =?UTF-8?q?fix(web):=20=ED=99=88=20=EB=B7=B0=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20staleTime=20=EC=A0=9C=EA=B1=B0=EB=A1=9C=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80=20=EC=8B=9C=20=EC=9E=AC=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전역 staleTime(5분) 설정 때문에 기본/주간 뷰를 토글해 이전에 방문했던 조합으로 돌아가면 캐시만 쓰고 재요청을 안 하던 문제를 수정했습니다 - 홈 뷰 쿼리에 staleTime: 0을 지정해 토글 시 항상 최신 데이터를 다시 요청하도록 했습니다 --- .../(main)/(with-time-sidebar)/home/_queries/use-home-view.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view.ts index 5ddce1b4..bcc2a2bf 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view.ts @@ -15,4 +15,5 @@ export const useHomeView = ({ filter, baseDate }: GetHomeViewParams) => queryKey: getGetHomeQueryKey({ filter, baseDate }), queryFn: ({ signal }) => getHome({ filter, baseDate }, undefined, signal), select: ({ data }) => homeViewDataSchema.parse(data), + staleTime: 0, }); From dca3e17ef4afe8d3f409897393a3b68669f5b596 Mon Sep 17 00:00:00 2001 From: kimminna Date: Mon, 13 Jul 2026 18:14:31 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat(web):=20=ED=88=AC=EB=91=90=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 드래그 앤 드롭으로 순서 변경 시 mock 대신 실제 reorderTodo 뮤테이션(PATCH /api/v1/todos/{todoId}/order)을 호출하도록 연동했습니다 - 성공 시 홈 뷰 쿼리를 무효화해 서버 최신 정렬 순서를 다시 조회하고, 실패 시 이전 순서로 롤백합니다 - 더 이상 쓰이지 않는 todo-order-mock.ts를 삭제했습니다 --- .../home/_hooks/use-home-todos-by-date.ts | 19 ++++++++++++------- .../home/_mocks/todo-order-mock.ts | 19 ------------------- 2 files changed, 12 insertions(+), 26 deletions(-) delete mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-order-mock.ts diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts index 50fbff35..6abd08f8 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts @@ -10,8 +10,8 @@ import { getGetHomeQueryKey } from "@/api/generated/endpoints/home/home"; import { useChangeSubtaskStatus, useChangeTodoStatus, + useReorderTodo, } from "@/api/generated/endpoints/todo/todo"; -import { patchTodoOrderMock } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-order-mock"; import { reorderTodos } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-order"; import { useTimeSidebarStore } from "@/stores/time-sidebar/useTimeSidebarStore"; @@ -21,6 +21,7 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { const queryClient = useQueryClient(); const { mutate: changeTodoStatus } = useChangeTodoStatus(); const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); + const { mutate: reorderTodo } = useReorderTodo(); useEffect(() => { setTodosByDate( @@ -105,7 +106,7 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { ); }; - const handleReorderTodo = async ( + const handleReorderTodo = ( dateKey: string, fromIndex: number, toIndex: number, @@ -119,11 +120,15 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { const reordered = reorderTodos(previous, fromIndex, toIndex); setTodosByDate((prev) => ({ ...prev, [dateKey]: reordered })); - try { - await patchTodoOrderMock({ todoId: movedTodo.todoId, newIndex: toIndex }); - } catch { - setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); - } + reorderTodo( + { todoId: movedTodo.todoId, data: { newIndex: toIndex, date: dateKey } }, + { + onSuccess: invalidateHomeView, + onError: () => { + setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); + }, + }, + ); }; return { diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-order-mock.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-order-mock.ts deleted file mode 100644 index 425ecd82..00000000 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-order-mock.ts +++ /dev/null @@ -1,19 +0,0 @@ -const MOCK_LATENCY_MS = 300; - -export interface ReorderTodoParams { - todoId: number; - newIndex: number; -} - -/** - * PATCH /todos/{todoId}/order 를 흉내내는 mock 함수. - * 실제 API 연동 시 이 함수만 axios 클라이언트 호출로 교체하면 된다. - */ -export const patchTodoOrderMock = async ({ - todoId, - newIndex, -}: ReorderTodoParams): Promise => { - await new Promise((resolve) => setTimeout(resolve, MOCK_LATENCY_MS)); - - return { todoId, newIndex }; -}; From de08d8891c04a208989b624abc691946871ac0e0 Mon Sep 17 00:00:00 2001 From: kimminna Date: Mon, 13 Jul 2026 19:56:12 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix(web):=20OverlayModal=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=A0=88=EC=9D=B4=20=ED=81=B4=EB=A6=AD=20=EA=B0=90?= =?UTF-8?q?=EC=A7=80=EC=97=90=20=ED=84=B0=EC=B9=98/=ED=8E=9C=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=A7=80=EC=9B=90=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onMouseDownCapture를 onPointerDownCapture로 교체해 태블릿 터치/펜슬 입력에서도 플로팅 레이어 상태를 정확히 감지하도록 수정 --- apps/timo-web/components/modal/OverlayModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/timo-web/components/modal/OverlayModal.tsx b/apps/timo-web/components/modal/OverlayModal.tsx index ce22ecc2..68ac4d4b 100644 --- a/apps/timo-web/components/modal/OverlayModal.tsx +++ b/apps/timo-web/components/modal/OverlayModal.tsx @@ -78,7 +78,7 @@ export const OverlayModal = ({ "bg-timo-overlay fixed inset-0 z-40 transition-opacity duration-200 ease-out", isVisible ? "opacity-100" : "opacity-0", )} - onMouseDownCapture={() => { + onPointerDownCapture={() => { wasFloatingLayerOpenRef.current = hasOpenFloatingLayer(); }} onClick={() => {