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={() => {