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/HomeTodoContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx index 704e5abe..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 @@ -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 { getHomeViewMock } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/home-view-mock"; -import { reorderDaysTodayFirst } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/home-view"; +import { useHomeView } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view"; import { DndSortableListProvider } from "@/providers/dnd/DndSortableListProvider"; import { formatDateKey } from "@/utils/date"; @@ -38,27 +40,19 @@ export const HomeTodoContainer = () => { const filter: HomeViewFilter = isWeekView ? "WEEK" : "DEFAULT"; const baseDate = formatDateKey(referenceDate); - const apiDays = useMemo( - () => getHomeViewMock({ filter, baseDate }).days, - [filter, baseDate], - ); - - const days = useMemo( - () => (isWeekView ? apiDays : reorderDaysTodayFirst(apiDays)), - [isWeekView, apiDays], - ); + const { data: homeViewData } = useHomeView({ filter, baseDate }); + const days = homeViewData.days; const { todosByDate, - handleAddTodo, handleToggleCompleted, handleTogglePlay, handleToggleSubtaskCompleted, handleReorderTodo, - } = useHomeTodosByDate(apiDays); + } = useHomeTodosByDate(days); useEffect(() => { - scrollRef.current?.scrollTo({ left: 0 }); + scrollContainerToToday(scrollRef.current); }, [isWeekView, scrollRef]); return ( @@ -77,6 +71,7 @@ export const HomeTodoContainer = () => { return (
{ isToday={day.isToday} totalCount={todos.length} completedCount={completedCount} - onCreateTodo={(todo) => handleAddTodo(dateKey, todo)} /> { 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/_containers/todo-card/HomeDayHeaderContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeDayHeaderContainer.tsx index d26e4111..7f2c897c 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeDayHeaderContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeDayHeaderContainer.tsx @@ -3,7 +3,6 @@ import { useTranslations } from "next-intl"; import type { ApiDayOfWeek } 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 { HomeDateInformation } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_components/todo-card/HomeDateInformation"; import { CreateTodoModalContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContainer"; @@ -17,7 +16,6 @@ export interface HomeDayHeaderContainerProps { isToday: boolean; totalCount: number; completedCount: number; - onCreateTodo: (todo: 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 6b461520..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,22 +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 { handleSubmit } = useCreateTodoSubmit({ onCreate }); + const tToast = useTranslations("Toast"); + const { handleSubmit, isErrorToastOpen, closeErrorToast } = + useCreateTodoSubmit(); const handleAddClick = () => { overlay.open(({ isOpen, close, unmount }) => ( @@ -33,5 +32,15 @@ export const CreateTodoModalContainer = ({ )); }; - return ; + return ( + <> + + + + + ); }; 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-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..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,42 +1,60 @@ +"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 { convertApiDurationToSeconds } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-time"; +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"; -// 목데이터: 실제 API 호출 없이 로컬 상태에만 즉시 반영한다. -const buildTodoFromRequest = ( +const buildCreateTodoRequestBody = ( data: CreateTodoRequest, - tag: CreateTodoTag, -): Todo => ({ - todoId: Date.now(), - icon: data.icon, +): TodoCreateRequest => ({ + 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, - })), + 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, }); -export interface UseCreateTodoSubmitParams { - onCreate: (todo: Todo) => void; -} +export const useCreateTodoSubmit = () => { + const [isErrorToastOpen, setIsErrorToastOpen] = useState(false); + const { mutate: createTodo } = useCreateTodo(); + const queryClient = useQueryClient(); + + const handleSubmit = (data: CreateTodoRequest) => { + createTodo( + { data: buildCreateTodoRequestBody(data) }, + { + onSuccess: (response) => { + const parsed = todoCreateResponseSchema.safeParse(response.data); -export const useCreateTodoSubmit = ({ - onCreate, -}: UseCreateTodoSubmitParams) => { - const handleSubmit = (data: CreateTodoRequest, tag: CreateTodoTag) => { - onCreate(buildTodoFromRequest(data, tag)); + if (!parsed.success) { + setIsErrorToastOpen(true); + return; + } + + queryClient.invalidateQueries({ queryKey: getGetHomeQueryKey() }); + }, + onError: () => { + setIsErrorToastOpen(true); + }, + }, + ); }; - return { handleSubmit }; + return { + handleSubmit, + isErrorToastOpen, + closeErrorToast: () => setIsErrorToastOpen(false), + }; }; 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/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-today-scroll.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-today-scroll.ts index aff6134f..91ff90a4 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-today-scroll.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-today-scroll.ts @@ -6,6 +6,33 @@ type ScrollToTodayListener = () => 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/_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..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 @@ -1,17 +1,27 @@ "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 { patchTodoOrderMock } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-order-mock"; +import { getGetHomeQueryKey } from "@/api/generated/endpoints/home/home"; +import { + useChangeSubtaskStatus, + useChangeTodoStatus, + useReorderTodo, +} from "@/api/generated/endpoints/todo/todo"; import { reorderTodos } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-order"; 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(); + const { mutate: reorderTodo } = useReorderTodo(); useEffect(() => { setTodosByDate( @@ -32,12 +42,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 +51,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,16 +87,26 @@ 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 ( + const handleReorderTodo = ( dateKey: string, fromIndex: number, toIndex: number, @@ -94,16 +120,19 @@ 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 { todosByDate, - handleAddTodo, handleToggleCompleted, handleTogglePlay, handleToggleSubtaskCompleted, 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 }; -}; 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..bcc2a2bf --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view.ts @@ -0,0 +1,19 @@ +"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), + staleTime: 0, + }); 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..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 @@ -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,21 +21,29 @@ export const todoSubtaskSchema = z.object({ export const todoSchema = z.object({ todoId: z.number(), - icon: z.string().nullable(), + icon: z + .string() + .nullish() + .transform((v) => v ?? undefined), title: z.string(), completed: z.boolean(), - durationSeconds: z.number(), - priority: todoPrioritySchema, - tag: todoTagSchema, + durationSeconds: z.number().default(0), + priority: todoPrioritySchema.nullish().transform((v) => v ?? "MEDIUM"), + tag: todoTagSchema.nullish().transform((v) => v ?? undefined), hasMemo: z.boolean(), isRepeated: z.boolean(), timerStatus: todoTimerStatusSchema, - sortOrder: z.number(), + sortOrder: z.number().default(0), 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/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]; -}; diff --git a/apps/timo-web/components/modal/OverlayModal.tsx b/apps/timo-web/components/modal/OverlayModal.tsx index 4cc6d292..68ac4d4b 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", )} + onPointerDownCapture={() => { + wasFloatingLayerOpenRef.current = hasOpenFloatingLayer(); + }} onClick={() => { - if (hasOpenFloatingLayer()) return; + if (wasFloatingLayerOpenRef.current) return; onClose(); }} aria-hidden="true" diff --git a/apps/timo-web/messages/en.json b/apps/timo-web/messages/en.json index 2447a07d..4b2ff9cd 100644 --- a/apps/timo-web/messages/en.json +++ b/apps/timo-web/messages/en.json @@ -110,7 +110,9 @@ "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.", + "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 384eaebf..19906979 100644 --- a/apps/timo-web/messages/ko.json +++ b/apps/timo-web/messages/ko.json @@ -110,7 +110,9 @@ "tagLimit": "태그는 최대 8개까지 만들 수 있으며, 추가하려면 기존 태그를 삭제해 주세요.", "todoLimitLine1": "완료되지 않은 투두는 최대 20개까지 추가할 수 있어요.", "todoLimitLine2": "새로운 투두를 추가하려면 기존 투두를 완료해주세요.", - "onboardingSubmitFailed": "온보딩 저장에 실패했어요. 다시 시도해 주세요." + "onboardingSubmitFailed": "온보딩 저장에 실패했어요. 다시 시도해 주세요.", + "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} />