From e62b92ee68f05718ee5e4cd520c67c5996da02cf Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 8 Jul 2026 02:09:37 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(web):=20Suspense/ErrorBoundary=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EB=9E=98=ED=8D=BC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useSearchParams 등 클라이언트 훅 사용 시 필요한 Suspense 경계와, 향후 비동기 데이터 페칭 시 필요한 에러 처리를 공통 AsyncBoundary 컴포넌트로 추가했습니다. --- .../components/boundary/AsyncBoundary.tsx | 23 ++++++++++ .../components/boundary/ErrorBoundary.tsx | 46 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 apps/timo-web/components/boundary/AsyncBoundary.tsx create mode 100644 apps/timo-web/components/boundary/ErrorBoundary.tsx diff --git a/apps/timo-web/components/boundary/AsyncBoundary.tsx b/apps/timo-web/components/boundary/AsyncBoundary.tsx new file mode 100644 index 00000000..c2e303f1 --- /dev/null +++ b/apps/timo-web/components/boundary/AsyncBoundary.tsx @@ -0,0 +1,23 @@ +import { Suspense, type ReactNode } from "react"; + +import { ErrorBoundary } from "@/components/boundary/ErrorBoundary"; + +interface AsyncBoundaryProps { + children: ReactNode; + pendingFallback?: ReactNode; + errorFallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode); +} + +export const AsyncBoundary = ({ + children, + pendingFallback = null, + errorFallback, +}: AsyncBoundaryProps) => { + const content = {children}; + + if (!errorFallback) { + return content; + } + + return {content}; +}; diff --git a/apps/timo-web/components/boundary/ErrorBoundary.tsx b/apps/timo-web/components/boundary/ErrorBoundary.tsx new file mode 100644 index 00000000..968b3460 --- /dev/null +++ b/apps/timo-web/components/boundary/ErrorBoundary.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Component } from "react"; + +import type { ReactNode } from "react"; + +type ErrorFallback = + | ReactNode + | ((error: Error, reset: () => void) => ReactNode); + +interface ErrorBoundaryProps { + children: ReactNode; + fallback: ErrorFallback; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + reset = (): void => { + this.setState({ error: null }); + }; + + render(): ReactNode { + const { error } = this.state; + const { children, fallback } = this.props; + + if (error) { + return typeof fallback === "function" + ? fallback(error, this.reset) + : fallback; + } + + return children; + } +} From 26f7f7a55593f509f6c0f816cc95fd177126b614 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 8 Jul 2026 14:50:54 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(ui):=20AddTaskButton=20=EB=84=88?= =?UTF-8?q?=EB=B9=84=EB=A5=BC=20=EC=9C=A0=EB=8F=99=EC=A0=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=9C=EC=84=A0=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고정 min-width를 제거하고 부모 폭에 맞춰 유동적으로 늘어나거나 줄어들도록 했습니다 - default와 폭 차이가 유일한 구분점이던 weekly variant를 제거했습니다 - 텍스트 span을 항상 truncate 가능하도록 통일했습니다 --- .../add-task-button/AddTaskButton.stories.tsx | 9 +-------- .../button/add-task-button/AddTaskButton.tsx | 18 +++++------------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/packages/timo-design-system/src/components/button/add-task-button/AddTaskButton.stories.tsx b/packages/timo-design-system/src/components/button/add-task-button/AddTaskButton.stories.tsx index bbdc2181..5c0e97e0 100644 --- a/packages/timo-design-system/src/components/button/add-task-button/AddTaskButton.stories.tsx +++ b/packages/timo-design-system/src/components/button/add-task-button/AddTaskButton.stories.tsx @@ -14,7 +14,7 @@ const meta = { }, variant: { control: "select", - options: ["default", "weekly", "big"], + options: ["default", "big"], }, }, } satisfies Meta; @@ -29,13 +29,6 @@ export const Default: Story = { }, }; -export const Weekly: Story = { - args: { - text: "Add task", - variant: "weekly", - }, -}; - export const Big: Story = { args: { text: "Add task", diff --git a/packages/timo-design-system/src/components/button/add-task-button/AddTaskButton.tsx b/packages/timo-design-system/src/components/button/add-task-button/AddTaskButton.tsx index 814c3b09..aeb1219c 100644 --- a/packages/timo-design-system/src/components/button/add-task-button/AddTaskButton.tsx +++ b/packages/timo-design-system/src/components/button/add-task-button/AddTaskButton.tsx @@ -1,12 +1,11 @@ import { PlusGrayIcon } from "../../../icons"; import { cn } from "../../../lib"; -export type AddTaskButtonVariant = "default" | "weekly" | "big"; +export type AddTaskButtonVariant = "default" | "big"; const ADD_TASK_BUTTON_VARIANT: Record = { - default: "min-w-57.5 px-2 typo-body-m-12", - weekly: "min-w-29 px-2 typo-body-m-12", - big: "h-14.5 min-w-155 px-5 typo-headline-m-14", + default: "px-2 typo-body-m-12", + big: "h-14.5 px-5 typo-headline-m-14", }; export interface AddTaskButtonProps { @@ -20,14 +19,12 @@ export const AddTaskButton = ({ variant = "default", onClick, }: AddTaskButtonProps) => { - const isWeekly = variant === "weekly"; - return ( From bf37f2fa25195e40a980868278c72722add8646c Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 8 Jul 2026 14:51:57 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat(web):=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=207=EC=9D=BC=20=EB=B7=B0=20mock/=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83/i18n=20=ED=82=A4=20=EC=A0=95=EB=A6=AC=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 필터(DEFAULT/WEEK)·기준일 기반 home-view mock 데이터 레이어를 구성했습니다 - 헤더의 뷰 전환(기본/7일)을 실제 데이터 조회 및 오늘로 스크롤 이동과 연결했습니다 - 7일 뷰에서 날짜 컬럼과 투두 카드가 남은 공간을 유동적으로 나눠 갖도록 하고, 최소 150px 폭을 보장하며 부족하면 가로 스크롤되도록 했습니다 - priority/요일 i18n 메시지 키를 API enum 값(대문자)과 통일해 중복된 라벨 매핑 유틸을 제거했습니다 --- .../home/_components/HomeTodoCard.tsx | 17 +- .../home/_containers/HomeHeaderContainer.tsx | 25 +- .../home/_containers/HomeTodoContainer.tsx | 263 ++++++++++-------- .../home/_hooks/useHomeTodayScroll.ts | 30 ++ .../home/_hooks/useHomeViewMode.ts | 80 ++++++ .../home/_mocks/home-view-mock.ts | 55 ++++ .../home/_mocks/todo-mock.ts | 33 ++- .../home/_types/home-view-type.ts | 41 +++ .../home/_types/todo-type.ts | 64 ++--- .../(with-time-sidebar)/home/_utils/date.ts | 39 +++ .../home/_utils/day-of-week.ts | 6 + .../home/_utils/home-view.ts | 13 + .../(main)/(with-time-sidebar)/home/page.tsx | 15 +- apps/timo-web/messages/en.json | 20 +- apps/timo-web/messages/ko.json | 20 +- apps/timo-web/utils/get-day-of-week-key.ts | 16 +- 16 files changed, 532 insertions(+), 205 deletions(-) create mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodayScroll.ts create mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeViewMode.ts create mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/home-view-mock.ts create mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type.ts create mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/day-of-week.ts create 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/_components/HomeTodoCard.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/HomeTodoCard.tsx index b4db4793..d7c5c285 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/HomeTodoCard.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/HomeTodoCard.tsx @@ -14,6 +14,7 @@ import { TagIcon, } from "@repo/timo-design-system/ui"; import { cn } from "@repo/timo-design-system/utils"; +import { useTranslations } from "next-intl"; import type { TodoPriorityTypes, @@ -37,7 +38,6 @@ export interface HomeTodoCardProps { isCompleted: boolean; durationSeconds: number; priority: TodoPriorityTypes; - priorityLabel?: string; tagName?: string; hasMemo: boolean; isRepeated: boolean; @@ -54,7 +54,6 @@ export const HomeTodoCard = ({ isCompleted, durationSeconds, priority, - priorityLabel, tagName, hasMemo, isRepeated, @@ -65,11 +64,15 @@ export const HomeTodoCard = ({ onTogglePlay, onToggleSubtaskCompleted, }: HomeTodoCardProps) => { + const tCommon = useTranslations("Common"); + const isRunning = timerStatus === "RUNNING"; + const priorityLabel = tCommon(`priority.${PRIORITY_MAP[priority]}`); + const titleRow = (
-
+

@@ -111,13 +114,15 @@ export const HomeTodoCard = ({ {subtaskTitle ? (

{titleRow} -
+
onToggleSubtaskCompleted?.(checked)} disabled={isCompleted} /> -

{subtaskTitle}

+

+ {subtaskTitle} +

) : ( diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeHeaderContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeHeaderContainer.tsx index c275729e..3831d30d 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeHeaderContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeHeaderContainer.tsx @@ -1,8 +1,9 @@ "use client"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { triggerScrollToToday } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodayScroll"; +import { useHomeViewMode } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeViewMode"; import { Header } from "@/components/layout/header/Header"; import { useNavigationSidebar } from "@/components/layout/sidebar/navigation/NavigationSidebarContext"; @@ -10,22 +11,36 @@ export const HomeHeaderContainer = () => { const t = useTranslations("Home"); const basicLabel = t("viewBasic"); const weekLabel = t("viewWeek"); + const viewOptions = [basicLabel, weekLabel]; - const [isWeekView, setIsWeekView] = useState(false); + const { isOpen, toggle } = useNavigationSidebar(); + const { isWeekView, setViewMode, goToNextWeek, goToPrevWeek, goToToday } = + useHomeViewMode(); const handleChangeView = (value: string) => { - if (value === basicLabel || value === weekLabel) { - setIsWeekView(value === weekLabel); + if (value === basicLabel) { + setViewMode("basic"); + } else if (value === weekLabel) { + setViewMode("week"); } }; + const handleGoToToday = () => { + goToToday(); + triggerScrollToToday(); + }; + return (
- + {isWeekView ? ( + + ) : ( + + )} } right={ 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 0d523fe8..64c06c39 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 @@ -1,151 +1,178 @@ "use client"; import { AddTaskButton } from "@repo/timo-design-system/ui"; +import { cn } from "@repo/timo-design-system/utils"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; -import type { - Todo, - TodoPriorityTypes, - TodoTagName, -} from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; +import type { HomeViewFilter } 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/HomeDateInformation"; import { HomeTodoCard } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_components/HomeTodoCard"; -import { todoMocks } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock"; -import { convertDateToDateText } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date"; -import { getDayOfWeekKey } from "@/utils/get-day-of-week-key"; - -type TagLabelKey = - | "dailyLife" - | "work" - | "exercise" - | "assignment" - | "additional"; - -const TAG_LABEL_KEY: Record = { - DAILY_LIFE: "dailyLife", - WORK: "work", - EXERCISE: "exercise", - ASSIGNMENT: "assignment", - ADDITIONAL: "additional", -}; - -type PriorityLabelKey = "veryImportant" | "important" | "average" | "low"; - -const PRIORITY_LABEL_KEY: Record = { - URGENT: "veryImportant", - HIGH: "important", - MEDIUM: "average", - LOW: "low", -}; +import { useHomeTodayScrollRef } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodayScroll"; +import { useHomeViewMode } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeViewMode"; +import { getHomeViewMock } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/home-view-mock"; +import { + convertDateToDateText, + formatDateKey, + getToday, + parseDateKey, +} from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date"; +import { reorderDaysTodayFirst } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/home-view"; export const HomeTodoContainer = () => { const t = useTranslations("Home"); const tCommon = useTranslations("Common"); - const [todos, setTodos] = useState(todoMocks); + const { isWeekView, referenceDate } = useHomeViewMode(); + const scrollRef = useHomeTodayScrollRef(); - const today = new Date(); - const completedCount = todos.filter((todo) => todo.completed).length; + const filter: HomeViewFilter = isWeekView ? "WEEK" : "DEFAULT"; + const baseDate = formatDateKey(referenceDate); + const apiDays = useMemo( + () => getHomeViewMock({ filter, baseDate }).days, + [filter, baseDate], + ); - const handleToggleCompleted = (todoId: number, completed: boolean) => { - // TODO: API - setTodos((prevTodos) => - prevTodos.map((todo) => - todo.todoId === todoId ? { ...todo, completed } : todo, - ), + const days = useMemo( + () => (isWeekView ? apiDays : reorderDaysTodayFirst(apiDays)), + [isWeekView, apiDays], + ); + + const [todosByDate, setTodosByDate] = useState>({}); + + useEffect(() => { + setTodosByDate( + Object.fromEntries(apiDays.map((day) => [day.date, day.todos])), ); + }, [apiDays]); + + const updateTodo = ( + dateKey: string, + todoId: number, + updater: (todo: Todo) => Todo, + ) => { + setTodosByDate((prev) => ({ + ...prev, + [dateKey]: (prev[dateKey] ?? []).map((todo) => + todo.todoId === todoId ? updater(todo) : todo, + ), + })); }; - const handleTogglePlay = (todoId: number) => { + const handleToggleCompleted = ( + dateKey: string, + todoId: number, + completed: boolean, + ) => { // TODO: API - setTodos((prevTodos) => - prevTodos.map((todo) => - todo.todoId === todoId - ? { - ...todo, - timerStatus: - todo.timerStatus === "RUNNING" ? "STOPPED" : "RUNNING", - } - : todo, - ), - ); + updateTodo(dateKey, todoId, (todo) => ({ ...todo, completed })); + }; + + const handleTogglePlay = (dateKey: string, todoId: number) => { + // TODO: API + updateTodo(dateKey, todoId, (todo) => ({ + ...todo, + timerStatus: todo.timerStatus === "RUNNING" ? "STOPPED" : "RUNNING", + })); }; const handleToggleSubtaskCompleted = ( + dateKey: string, todoId: number, subtaskId: number, completed: boolean, ) => { // TODO: API - setTodos((prevTodos) => - prevTodos.map((todo) => - todo.todoId === todoId - ? { - ...todo, - subtasks: todo.subtasks.map((subtask) => - subtask.subtaskId === subtaskId - ? { ...subtask, completed } - : subtask, - ), - } - : todo, + updateTodo(dateKey, todoId, (todo) => ({ + ...todo, + subtasks: todo.subtasks.map((subtask) => + subtask.subtaskId === subtaskId ? { ...subtask, completed } : subtask, ), - ); + })); }; return ( -
-
- - -
- -
- {todos.map((todo) => { - const [firstSubtask] = todo.subtasks; - - return ( - - handleToggleCompleted(todo.todoId, completed) - } - onTogglePlay={() => handleTogglePlay(todo.todoId)} - onToggleSubtaskCompleted={ - firstSubtask - ? (completed) => - handleToggleSubtaskCompleted( - todo.todoId, - firstSubtask.subtaskId, - completed, - ) - : undefined - } - /> - ); - })} -
+
+ {days.map((day) => { + const dateKey = day.date; + const todos = todosByDate[dateKey] ?? day.todos; + const date = parseDateKey(day.date) ?? getToday(); + const completedCount = todos.filter((todo) => todo.completed).length; + + return ( +
+
+ + +
+ +
+ {todos.map((todo) => { + const [firstSubtask] = todo.subtasks; + + return ( +
+ + handleToggleCompleted(dateKey, todo.todoId, completed) + } + onTogglePlay={() => + handleTogglePlay(dateKey, todo.todoId) + } + onToggleSubtaskCompleted={ + firstSubtask + ? (completed) => + handleToggleSubtaskCompleted( + dateKey, + todo.todoId, + firstSubtask.subtaskId, + completed, + ) + : undefined + } + /> +
+ ); + })} +
+
+ ); + })}
); }; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodayScroll.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodayScroll.ts new file mode 100644 index 00000000..aff6134f --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodayScroll.ts @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +type ScrollToTodayListener = () => void; + +const listeners = new Set(); + +export const triggerScrollToToday = (): void => { + listeners.forEach((listener) => listener()); +}; + +export const useHomeTodayScrollRef = < + T extends HTMLElement, +>(): React.RefObject => { + const containerRef = useRef(null); + + useEffect(() => { + const listener: ScrollToTodayListener = () => { + containerRef.current?.scrollTo({ left: 0, behavior: "smooth" }); + }; + + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, []); + + return containerRef; +}; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeViewMode.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeViewMode.ts new file mode 100644 index 00000000..9db0a9c2 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeViewMode.ts @@ -0,0 +1,80 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { useCallback, useMemo } from "react"; + +import { + addDays, + formatDateKey, + getToday, + parseDateKey, +} from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date"; +import { usePathname, useRouter } from "@/i18n/navigation"; + +const VIEW_PARAM = "view"; +const DATE_PARAM = "date"; +const WEEK_VIEW_VALUE = "week"; +const DAYS_PER_WEEK = 7; + +export type HomeViewMode = "basic" | "week"; + +export const useHomeViewMode = () => { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const isWeekView = searchParams.get(VIEW_PARAM) === WEEK_VIEW_VALUE; + + const referenceDate = useMemo(() => { + const dateParam = searchParams.get(DATE_PARAM); + const parsedDate = dateParam ? parseDateKey(dateParam) : null; + return parsedDate ?? getToday(); + }, [searchParams]); + + const navigate = useCallback( + (mode: HomeViewMode, date: Date) => { + const params = new URLSearchParams(); + + if (mode === "week") { + params.set(VIEW_PARAM, WEEK_VIEW_VALUE); + } + + const dateKey = formatDateKey(date); + if (dateKey !== formatDateKey(getToday())) { + params.set(DATE_PARAM, dateKey); + } + + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname); + }, + [pathname, router], + ); + + const setViewMode = useCallback( + (mode: HomeViewMode) => { + navigate(mode, mode === "week" ? referenceDate : getToday()); + }, + [navigate, referenceDate], + ); + + const goToNextWeek = useCallback(() => { + navigate("week", addDays(referenceDate, DAYS_PER_WEEK)); + }, [navigate, referenceDate]); + + const goToPrevWeek = useCallback(() => { + navigate("week", addDays(referenceDate, -DAYS_PER_WEEK)); + }, [navigate, referenceDate]); + + const goToToday = useCallback(() => { + navigate(isWeekView ? "week" : "basic", getToday()); + }, [navigate, isWeekView]); + + return { + isWeekView, + referenceDate, + setViewMode, + goToNextWeek, + goToPrevWeek, + goToToday, + }; +}; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/home-view-mock.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/home-view-mock.ts new file mode 100644 index 00000000..d7c5f256 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/home-view-mock.ts @@ -0,0 +1,55 @@ +import type { + GetHomeViewParams, + HomeViewData, + HomeViewDay, +} from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type"; + +import { getTodoMocksByDate } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock"; +import { + addDays, + buildDateRange, + formatDateKey, + getToday, + isSameDate, + parseDateKey, +} from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date"; +import { getApiDayOfWeek } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/day-of-week"; + +const BASIC_RANGE_DAYS_BEFORE = 7; +const BASIC_RANGE_LENGTH = 15; +const WEEK_RANGE_LENGTH = 7; + +export const getHomeViewMock = ({ + filter, + baseDate, +}: GetHomeViewParams): HomeViewData => { + const referenceDate = parseDateKey(baseDate) ?? getToday(); + + const dates = + filter === "WEEK" + ? buildDateRange(referenceDate, WEEK_RANGE_LENGTH) + : buildDateRange( + addDays(referenceDate, -BASIC_RANGE_DAYS_BEFORE), + BASIC_RANGE_LENGTH, + ); + + const days: HomeViewDay[] = dates.map((date) => { + const todos = getTodoMocksByDate(date); + + return { + date: formatDateKey(date), + dayOfWeek: getApiDayOfWeek(date), + isHoliday: false, + isToday: isSameDate(date, getToday()), + totalCount: todos.length, + completedCount: todos.filter((todo) => todo.completed).length, + todos, + }; + }); + + return { + filter, + baseDate: formatDateKey(referenceDate), + days, + }; +}; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts index 8dad6cab..a2b116b7 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts @@ -1,14 +1,17 @@ -import { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; +import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; -export const todoMocks: Todo[] = [ +import { formatDateKey } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date"; + +type TodoTemplate = Omit; + +const TODO_TEMPLATES: TodoTemplate[] = [ { - todoId: 145, icon: "ICON_3", title: "티모 하이와프 작업하기", completed: false, durationSeconds: 7200, priority: "HIGH", - tag: { tagId: 3, name: "WORK" }, + tag: { tagId: 3, name: "업무" }, hasMemo: false, isRepeated: true, timerStatus: "RUNNING", @@ -16,13 +19,12 @@ export const todoMocks: Todo[] = [ subtasks: [], }, { - todoId: 146, icon: "ICON_1", title: "앱잼 1차 과제 제출", completed: false, durationSeconds: 5400, priority: "URGENT", - tag: { tagId: 1, name: "ASSIGNMENT" }, + tag: { tagId: 1, name: "과제" }, hasMemo: true, isRepeated: false, timerStatus: "STOPPED", @@ -36,13 +38,12 @@ export const todoMocks: Todo[] = [ ], }, { - todoId: 147, icon: "ICON_2", title: "운동하기", completed: true, durationSeconds: 1800, priority: "LOW", - tag: { tagId: 4, name: "EXERCISE" }, + tag: { tagId: 4, name: "운동" }, hasMemo: false, isRepeated: true, timerStatus: "STOPPED", @@ -50,13 +51,12 @@ export const todoMocks: Todo[] = [ subtasks: [], }, { - todoId: 148, icon: "ICON_4", title: "독서 30분", completed: false, durationSeconds: 1800, priority: "MEDIUM", - tag: { tagId: 5, name: "ADDITIONAL" }, + tag: { tagId: 5, name: "기타" }, hasMemo: true, isRepeated: false, timerStatus: "STOPPED", @@ -64,13 +64,12 @@ export const todoMocks: Todo[] = [ subtasks: [], }, { - todoId: 149, icon: "ICON_5", title: "저녁 약속 준비", completed: false, durationSeconds: 3600, priority: "MEDIUM", - tag: { tagId: 2, name: "DAILY_LIFE" }, + tag: { tagId: 2, name: "일상" }, hasMemo: false, isRepeated: false, timerStatus: "STOPPED", @@ -78,3 +77,13 @@ export const todoMocks: Todo[] = [ subtasks: [], }, ]; + +export const getTodoMocksByDate = (date: Date): Todo[] => { + const dateKeyDigits = formatDateKey(date).replaceAll("-", ""); + const templateCount = (date.getDate() % TODO_TEMPLATES.length) + 1; + + return TODO_TEMPLATES.slice(0, templateCount).map((template, index) => ({ + ...template, + todoId: Number(`${dateKeyDigits}${index}`), + })); +}; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type.ts new file mode 100644 index 00000000..b4169510 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { todoSchema } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; + +export const homeViewFilterSchema = z.enum(["DEFAULT", "WEEK"]); + +export const apiDayOfWeekSchema = z.enum([ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN", +]); + +export const homeViewDaySchema = z.object({ + date: z.string(), + dayOfWeek: apiDayOfWeekSchema, + isHoliday: z.boolean(), + isToday: z.boolean(), + totalCount: z.number(), + completedCount: z.number(), + todos: z.array(todoSchema), +}); + +export const homeViewDataSchema = z.object({ + filter: homeViewFilterSchema, + baseDate: z.string(), + days: z.array(homeViewDaySchema), +}); + +export type HomeViewFilter = z.infer; +export type ApiDayOfWeek = z.infer; +export type HomeViewDay = z.infer; +export type HomeViewData = z.infer; + +export interface GetHomeViewParams { + filter: HomeViewFilter; + baseDate: string; +} 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 027b888b..d851c33a 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 @@ -1,34 +1,36 @@ -export type TodoPriorityTypes = "URGENT" | "HIGH" | "MEDIUM" | "LOW"; -export type TodoTimerStatusTypes = "RUNNING" | "STOPPED"; -export type TodoTagName = - | "DAILY_LIFE" - | "WORK" - | "EXERCISE" - | "ASSIGNMENT" - | "ADDITIONAL"; +import { z } from "zod"; -export interface TodoTag { - tagId: number; - name: TodoTagName; -} +export const todoPrioritySchema = z.enum(["URGENT", "HIGH", "MEDIUM", "LOW"]); +export const todoTimerStatusSchema = z.enum(["RUNNING", "STOPPED"]); -export interface TodoSubtask { - subtaskId: number; - content: string; - completed: boolean; -} +export const todoTagSchema = z.object({ + tagId: z.number(), + name: z.string(), +}); -export interface Todo { - todoId: number; - icon: string; - title: string; - completed: boolean; - durationSeconds: number; - priority: TodoPriorityTypes; - tag: TodoTag; - hasMemo: boolean; - isRepeated: boolean; - timerStatus: TodoTimerStatusTypes; - sortOrder: number; - subtasks: TodoSubtask[]; -} +export const todoSubtaskSchema = z.object({ + subtaskId: z.number(), + content: z.string(), + completed: z.boolean(), +}); + +export const todoSchema = z.object({ + todoId: z.number(), + icon: z.string(), + title: z.string(), + completed: z.boolean(), + durationSeconds: z.number(), + priority: todoPrioritySchema, + tag: todoTagSchema, + hasMemo: z.boolean(), + isRepeated: z.boolean(), + timerStatus: todoTimerStatusSchema, + sortOrder: z.number(), + subtasks: z.array(todoSubtaskSchema), +}); + +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; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date.ts index d7315124..aea3b821 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date.ts @@ -1,3 +1,5 @@ +const DATE_KEY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; + export const convertDateToDateText = (date: Date): string => { const day = date.getDate(); @@ -7,3 +9,40 @@ export const convertDateToDateText = (date: Date): string => { return `${day}`; }; + +const getStartOfDay = (date: Date): Date => + new Date(date.getFullYear(), date.getMonth(), date.getDate()); + +export const getToday = (): Date => getStartOfDay(new Date()); + +export const addDays = (date: Date, amount: number): Date => { + const result = new Date(date); + result.setDate(result.getDate() + amount); + return result; +}; + +export const isSameDate = (a: Date, b: Date): boolean => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); + +export const buildDateRange = (start: Date, length: number): Date[] => + Array.from({ length }, (_, index) => addDays(start, index)); + +export const formatDateKey = (date: Date): string => { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +export const parseDateKey = (value: string): Date | null => { + const match = DATE_KEY_PATTERN.exec(value); + + if (!match) { + return null; + } + + const [, year, month, day] = match; + return new Date(Number(year), Number(month) - 1, Number(day)); +}; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/day-of-week.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/day-of-week.ts new file mode 100644 index 00000000..5616a526 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/day-of-week.ts @@ -0,0 +1,6 @@ +import type { ApiDayOfWeek } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type"; + +import { getDayOfWeekKey } from "@/utils/get-day-of-week-key"; + +export const getApiDayOfWeek = (date: Date): ApiDayOfWeek => + getDayOfWeekKey(date) as ApiDayOfWeek; 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 new file mode 100644 index 00000000..fe003898 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/home-view.ts @@ -0,0 +1,13 @@ +import type { HomeViewDay } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type"; + +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/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx index 0f5bda89..7801a1d7 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx @@ -1,13 +1,18 @@ import { HomeHeaderContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeHeaderContainer"; import { HomeTodoContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer"; +import { AsyncBoundary } from "@/components/boundary/AsyncBoundary"; export default function HomePage() { return ( - <> - -
- +
+ + + +
+ + +
- +
); } diff --git a/apps/timo-web/messages/en.json b/apps/timo-web/messages/en.json index 3506a48e..4edc3ddb 100644 --- a/apps/timo-web/messages/en.json +++ b/apps/timo-web/messages/en.json @@ -8,13 +8,13 @@ }, "Common": { "weekday": { - "sunday": "Sunday", - "monday": "Monday", - "tuesday": "Tuesday", - "wednesday": "Wednesday", - "thursday": "Thursday", - "friday": "Friday", - "saturday": "Saturday" + "SUN": "Sunday", + "MON": "Monday", + "TUE": "Tuesday", + "WED": "Wednesday", + "THU": "Thursday", + "FRI": "Friday", + "SAT": "Saturday" }, "tag": { "dailyLife": "Daily life", @@ -24,9 +24,9 @@ "additional": "Additional" }, "priority": { - "veryImportant": "Very Important", - "important": "Important", - "average": "Average", + "urgent": "Very Important", + "high": "Important", + "medium": "Average", "low": "Low" } }, diff --git a/apps/timo-web/messages/ko.json b/apps/timo-web/messages/ko.json index 1071f6aa..033248c7 100644 --- a/apps/timo-web/messages/ko.json +++ b/apps/timo-web/messages/ko.json @@ -8,13 +8,13 @@ }, "Common": { "weekday": { - "sunday": "일요일", - "monday": "월요일", - "tuesday": "화요일", - "wednesday": "수요일", - "thursday": "목요일", - "friday": "금요일", - "saturday": "토요일" + "SUN": "일요일", + "MON": "월요일", + "TUE": "화요일", + "WED": "수요일", + "THU": "목요일", + "FRI": "금요일", + "SAT": "토요일" }, "tag": { "dailyLife": "일상", @@ -24,9 +24,9 @@ "additional": "기타" }, "priority": { - "veryImportant": "매우중요", - "important": "중요", - "average": "보통", + "urgent": "매우중요", + "high": "중요", + "medium": "보통", "low": "낮음" } }, diff --git a/apps/timo-web/utils/get-day-of-week-key.ts b/apps/timo-web/utils/get-day-of-week-key.ts index 2e1d2f6b..19926898 100644 --- a/apps/timo-web/utils/get-day-of-week-key.ts +++ b/apps/timo-web/utils/get-day-of-week-key.ts @@ -1,15 +1,15 @@ const DAY_OF_WEEK_KEYS = [ - "sunday", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", + "SUN", + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", ] as const; export type DayOfWeekKey = (typeof DAY_OF_WEEK_KEYS)[number]; export const getDayOfWeekKey = (date: Date): DayOfWeekKey => { - return DAY_OF_WEEK_KEYS[date.getDay()] ?? "sunday"; + return DAY_OF_WEEK_KEYS[date.getDay()] ?? "SUN"; }; From ff1553649dff9f38de30fc244fd16f82b1ffc57a Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 8 Jul 2026 22:50:36 +0900 Subject: [PATCH 4/9] =?UTF-8?q?chore(web):=20=EB=93=9C=EB=9E=98=EA=B7=B8?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC/=EC=A0=84=EC=97=AD=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 투두 순서 변경을 위해 dnd-kit 패키지를 추가했습니다 - 타이머 사이드바 전역 상태 관리를 위해 zustand 패키지를 추가했습니다 --- apps/timo-web/package.json | 6 ++- pnpm-lock.yaml | 82 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/apps/timo-web/package.json b/apps/timo-web/package.json index e4eb2372..92b8707a 100644 --- a/apps/timo-web/package.json +++ b/apps/timo-web/package.json @@ -11,6 +11,9 @@ "check-types": "next typegen && tsc --noEmit" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@repo/timo-design-system": "workspace:*", "@sentry/nextjs": "^10.61.0", "@tanstack/react-query": "^5.101.1", @@ -20,7 +23,8 @@ "next-intl": "^4.13.1", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "^5.0.14" }, "devDependencies": { "@repo/eslint-config": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc1c2405..a7e9c27e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,15 @@ importers: apps/timo-web: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.0) '@repo/timo-design-system': specifier: workspace:* version: link:../../packages/timo-design-system @@ -62,6 +71,9 @@ importers: zod: specifier: ^4.4.3 version: 4.4.3 + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.2)(react@19.2.0) devDependencies: '@repo/eslint-config': specifier: workspace:* @@ -314,6 +326,28 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -4426,6 +4460,24 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adobe/css-tools@4.5.0': {} @@ -4558,6 +4610,31 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@dnd-kit/accessibility@3.1.1(react@19.2.0)': + dependencies: + react: 19.2.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.0) + '@dnd-kit/utilities': 3.2.2(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@dnd-kit/utilities': 3.2.2(react@19.2.0) + react: 19.2.0 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.0)': + dependencies: + react: 19.2.0 + tslib: 2.8.1 + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -8732,3 +8809,8 @@ snapshots: yoctocolors-cjs@2.1.3: {} zod@4.4.3: {} + + zustand@5.0.14(@types/react@19.2.2)(react@19.2.0): + optionalDependencies: + '@types/react': 19.2.2 + react: 19.2.0 From 9a40f9d29f7c2e7b1b30649a65a507c381be3074 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 8 Jul 2026 22:52:30 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat(web):=20=ED=99=88=20=ED=88=AC=EB=91=90?= =?UTF-8?q?=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=95=A4=20=EB=93=9C=EB=A1=AD?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 투두 카드를 드래그로 순서 변경할 수 있는 기능을 추가했습니다 - 헤더/투두 상태 로직을 HomeDayHeaderContainer, useHomeTodosByDate로 분리했습니다 - 태그 이름을 i18n 키 기반으로 매핑하도록 변경했습니다 --- .../home/_components/HomeTodoCard.tsx | 17 ++ .../_containers/HomeDayHeaderContainer.tsx | 49 ++++++ .../home/_containers/HomeTodoContainer.tsx | 153 ++++++++---------- .../home/_hooks/useHomeTodosByDate.ts | 93 +++++++++++ .../home/_mocks/todo-mock.ts | 41 +++-- .../home/_mocks/todo-order-mock.ts | 19 +++ .../home/_utils/todo-order.ts | 21 +++ .../providers/dnd/DndSortableListProvider.tsx | 67 ++++++++ 8 files changed, 359 insertions(+), 101 deletions(-) create mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeDayHeaderContainer.tsx create mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodosByDate.ts create mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-order-mock.ts create mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-order.ts create mode 100644 apps/timo-web/providers/dnd/DndSortableListProvider.tsx diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/HomeTodoCard.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/HomeTodoCard.tsx index d7c5c285..d64be45e 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/HomeTodoCard.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/HomeTodoCard.tsx @@ -1,3 +1,5 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { MemoDisableIcon, MemoOnIcon, @@ -34,6 +36,7 @@ const PRIORITY_MAP: Record< }; export interface HomeTodoCardProps { + todoId: number; title: string; isCompleted: boolean; durationSeconds: number; @@ -50,6 +53,7 @@ export interface HomeTodoCardProps { } export const HomeTodoCard = ({ + todoId, title, isCompleted, durationSeconds, @@ -66,6 +70,15 @@ export const HomeTodoCard = ({ }: HomeTodoCardProps) => { const tCommon = useTranslations("Common"); + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: todoId, disabled: isCompleted }); + + const sortableStyle = { + transform: CSS.Transform.toString(transform), + transition, + touchAction: "none", + }; + const isRunning = timerStatus === "RUNNING"; const priorityLabel = tCommon(`priority.${PRIORITY_MAP[priority]}`); @@ -106,6 +119,10 @@ export const HomeTodoCard = ({ return (
{ + const t = useTranslations("Home"); + const tCommon = useTranslations("Common"); + const date = parseDateKey(dateKey) ?? getToday(); + + return ( +
+ + +
+ ); +}; 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 64c06c39..05c086e2 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 @@ -1,34 +1,43 @@ "use client"; -import { AddTaskButton } from "@repo/timo-design-system/ui"; import { cn } from "@repo/timo-design-system/utils"; import { useTranslations } from "next-intl"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo } from "react"; import type { HomeViewFilter } 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/HomeDateInformation"; import { HomeTodoCard } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_components/HomeTodoCard"; +import { HomeDayHeaderContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeDayHeaderContainer"; import { useHomeTodayScrollRef } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodayScroll"; +import { useHomeTodosByDate } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodosByDate"; import { useHomeViewMode } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeViewMode"; import { getHomeViewMock } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/home-view-mock"; -import { - convertDateToDateText, - formatDateKey, - getToday, - parseDateKey, -} from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date"; +import { formatDateKey } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date"; import { reorderDaysTodayFirst } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/home-view"; +import { DndSortableListProvider } from "@/providers/dnd/DndSortableListProvider"; + +const TAG_LABEL_KEYS = [ + "dailyLife", + "work", + "exercise", + "assignment", + "additional", +] as const; + +type TagLabelKey = (typeof TAG_LABEL_KEYS)[number]; + +const isTagLabelKey = (value: string): value is TagLabelKey => + (TAG_LABEL_KEYS as readonly string[]).includes(value); export const HomeTodoContainer = () => { - const t = useTranslations("Home"); const tCommon = useTranslations("Common"); const { isWeekView, referenceDate } = useHomeViewMode(); + const scrollRef = useHomeTodayScrollRef(); const filter: HomeViewFilter = isWeekView ? "WEEK" : "DEFAULT"; const baseDate = formatDateKey(referenceDate); + const apiDays = useMemo( () => getHomeViewMock({ filter, baseDate }).days, [filter, baseDate], @@ -39,71 +48,29 @@ export const HomeTodoContainer = () => { [isWeekView, apiDays], ); - const [todosByDate, setTodosByDate] = useState>({}); + const { + todosByDate, + handleToggleCompleted, + handleTogglePlay, + handleToggleSubtaskCompleted, + handleReorderTodo, + } = useHomeTodosByDate(apiDays); useEffect(() => { - setTodosByDate( - Object.fromEntries(apiDays.map((day) => [day.date, day.todos])), - ); - }, [apiDays]); - - const updateTodo = ( - dateKey: string, - todoId: number, - updater: (todo: Todo) => Todo, - ) => { - setTodosByDate((prev) => ({ - ...prev, - [dateKey]: (prev[dateKey] ?? []).map((todo) => - todo.todoId === todoId ? updater(todo) : todo, - ), - })); - }; - - const handleToggleCompleted = ( - dateKey: string, - todoId: number, - completed: boolean, - ) => { - // TODO: API - updateTodo(dateKey, todoId, (todo) => ({ ...todo, completed })); - }; - - const handleTogglePlay = (dateKey: string, todoId: number) => { - // TODO: API - updateTodo(dateKey, todoId, (todo) => ({ - ...todo, - timerStatus: todo.timerStatus === "RUNNING" ? "STOPPED" : "RUNNING", - })); - }; - - const handleToggleSubtaskCompleted = ( - dateKey: string, - todoId: number, - subtaskId: number, - completed: boolean, - ) => { - // TODO: API - updateTodo(dateKey, todoId, (todo) => ({ - ...todo, - subtasks: todo.subtasks.map((subtask) => - subtask.subtaskId === subtaskId ? { ...subtask, completed } : subtask, - ), - })); - }; + scrollRef.current?.scrollTo({ left: 0 }); + }, [isWeekView, scrollRef]); return (
{days.map((day) => { const dateKey = day.date; const todos = todosByDate[dateKey] ?? day.todos; - const date = parseDateKey(day.date) ?? getToday(); const completedCount = todos.filter((todo) => todo.completed).length; return ( @@ -116,33 +83,39 @@ export const HomeTodoContainer = () => { : "w-[230px] shrink-0 snap-start", )} > -
- - -
- -
- {todos.map((todo) => { - const [firstSubtask] = todo.subtasks; - - return ( -
+ + + todo.todoId)} + onReorder={(fromIndex, toIndex) => + handleReorderTodo(dateKey, fromIndex, toIndex) + } + > +
+ {todos.map((todo) => { + const [firstSubtask] = todo.subtasks; + + return ( { : undefined } /> -
- ); - })} -
+ ); + })} +
+
); })} diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodosByDate.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodosByDate.ts new file mode 100644 index 00000000..c9e3c0c3 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodosByDate.ts @@ -0,0 +1,93 @@ +"use client"; + +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 { reorderTodos } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-order"; + +export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { + const [todosByDate, setTodosByDate] = useState>({}); + + useEffect(() => { + setTodosByDate( + Object.fromEntries(apiDays.map((day) => [day.date, day.todos])), + ); + }, [apiDays]); + + const updateTodo = ( + dateKey: string, + todoId: number, + updater: (todo: Todo) => Todo, + ) => { + setTodosByDate((prev) => ({ + ...prev, + [dateKey]: (prev[dateKey] ?? []).map((todo) => + todo.todoId === todoId ? updater(todo) : todo, + ), + })); + }; + + const handleToggleCompleted = ( + dateKey: string, + todoId: number, + completed: boolean, + ) => { + // TODO: API + updateTodo(dateKey, todoId, (todo) => ({ ...todo, completed })); + }; + + const handleTogglePlay = (dateKey: string, todoId: number) => { + // TODO: API + updateTodo(dateKey, todoId, (todo) => ({ + ...todo, + timerStatus: todo.timerStatus === "RUNNING" ? "STOPPED" : "RUNNING", + })); + }; + + const handleToggleSubtaskCompleted = ( + dateKey: string, + todoId: number, + subtaskId: number, + completed: boolean, + ) => { + // TODO: API + updateTodo(dateKey, todoId, (todo) => ({ + ...todo, + subtasks: todo.subtasks.map((subtask) => + subtask.subtaskId === subtaskId ? { ...subtask, completed } : subtask, + ), + })); + }; + + const handleReorderTodo = async ( + dateKey: string, + fromIndex: number, + toIndex: number, + ) => { + const previous = todosByDate[dateKey] ?? []; + const movedTodo = previous[fromIndex]; + if (!movedTodo || movedTodo.completed) { + return; + } + + 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 })); + } + }; + + return { + todosByDate, + handleToggleCompleted, + handleTogglePlay, + handleToggleSubtaskCompleted, + handleReorderTodo, + }; +}; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts index a2b116b7..5300b8f9 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts @@ -1,9 +1,16 @@ import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; -import { formatDateKey } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date"; +import { + formatDateKey, + getToday, + isSameDate, +} from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date"; type TodoTemplate = Omit; +/** 오늘 날짜 컬럼의 세로 스크롤 동작을 시각적으로 확인하기 위한 투두 개수 */ +const SCROLL_TEST_TODO_COUNT = 10; + const TODO_TEMPLATES: TodoTemplate[] = [ { icon: "ICON_3", @@ -11,7 +18,7 @@ const TODO_TEMPLATES: TodoTemplate[] = [ completed: false, durationSeconds: 7200, priority: "HIGH", - tag: { tagId: 3, name: "업무" }, + tag: { tagId: 3, name: "work" }, hasMemo: false, isRepeated: true, timerStatus: "RUNNING", @@ -24,7 +31,7 @@ const TODO_TEMPLATES: TodoTemplate[] = [ completed: false, durationSeconds: 5400, priority: "URGENT", - tag: { tagId: 1, name: "과제" }, + tag: { tagId: 1, name: "assignment" }, hasMemo: true, isRepeated: false, timerStatus: "STOPPED", @@ -43,7 +50,7 @@ const TODO_TEMPLATES: TodoTemplate[] = [ completed: true, durationSeconds: 1800, priority: "LOW", - tag: { tagId: 4, name: "운동" }, + tag: { tagId: 4, name: "exercise" }, hasMemo: false, isRepeated: true, timerStatus: "STOPPED", @@ -56,7 +63,7 @@ const TODO_TEMPLATES: TodoTemplate[] = [ completed: false, durationSeconds: 1800, priority: "MEDIUM", - tag: { tagId: 5, name: "기타" }, + tag: { tagId: 5, name: "additional" }, hasMemo: true, isRepeated: false, timerStatus: "STOPPED", @@ -69,7 +76,7 @@ const TODO_TEMPLATES: TodoTemplate[] = [ completed: false, durationSeconds: 3600, priority: "MEDIUM", - tag: { tagId: 2, name: "일상" }, + tag: { tagId: 2, name: "dailyLife" }, hasMemo: false, isRepeated: false, timerStatus: "STOPPED", @@ -80,10 +87,22 @@ const TODO_TEMPLATES: TodoTemplate[] = [ export const getTodoMocksByDate = (date: Date): Todo[] => { const dateKeyDigits = formatDateKey(date).replaceAll("-", ""); - const templateCount = (date.getDate() % TODO_TEMPLATES.length) + 1; + const templateCount = isSameDate(date, getToday()) + ? SCROLL_TEST_TODO_COUNT + : (date.getDate() % TODO_TEMPLATES.length) + 1; + + return Array.from({ length: templateCount }, (_, index) => { + // index % TODO_TEMPLATES.length는 항상 유효한 범위 내 인덱스이다 + const template = TODO_TEMPLATES[index % TODO_TEMPLATES.length]!; - return TODO_TEMPLATES.slice(0, templateCount).map((template, index) => ({ - ...template, - todoId: Number(`${dateKeyDigits}${index}`), - })); + return { + ...template, + title: + templateCount > TODO_TEMPLATES.length + ? `${template.title} ${index + 1}` + : template.title, + sortOrder: index, + todoId: Number(`${dateKeyDigits}${index}`), + }; + }); }; 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 new file mode 100644 index 00000000..425ecd82 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-order-mock.ts @@ -0,0 +1,19 @@ +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/_utils/todo-order.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-order.ts new file mode 100644 index 00000000..34b67e4c --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-order.ts @@ -0,0 +1,21 @@ +import { arrayMove } from "@dnd-kit/sortable"; + +import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; + +/** + * 완료된 투두는 이동시킬 수 없으므로, fromIndex가 완료된 투두를 가리키면 원본을 그대로 반환한다. + */ +export const reorderTodos = ( + todos: Todo[], + fromIndex: number, + toIndex: number, +): Todo[] => { + if (todos[fromIndex]?.completed) { + return todos; + } + + return arrayMove(todos, fromIndex, toIndex).map((todo, index) => ({ + ...todo, + sortOrder: index, + })); +}; diff --git a/apps/timo-web/providers/dnd/DndSortableListProvider.tsx b/apps/timo-web/providers/dnd/DndSortableListProvider.tsx new file mode 100644 index 00000000..767421cb --- /dev/null +++ b/apps/timo-web/providers/dnd/DndSortableListProvider.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; + +import type { DragEndEvent } from "@dnd-kit/core"; +import type { ReactNode } from "react"; + +export interface DndSortableListProviderProps { + dndId: string; + itemIds: number[]; + onReorder: (fromIndex: number, toIndex: number) => void; + children: ReactNode; +} + +export const DndSortableListProvider = ({ + dndId, + itemIds, + onReorder, + children, +}: DndSortableListProviderProps) => { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + + const fromIndex = itemIds.indexOf(Number(active.id)); + const toIndex = itemIds.indexOf(Number(over.id)); + if (fromIndex === -1 || toIndex === -1) { + return; + } + + onReorder(fromIndex, toIndex); + }; + + return ( + + + {children} + + + ); +}; From de65eb7592609091aeb6bbd6969481a7a1b4aa86 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 8 Jul 2026 22:53:10 +0900 Subject: [PATCH 6/9] =?UTF-8?q?file(web):=20=ED=94=84=EB=A1=9C=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=EB=8D=94/=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=84=88=EB=A5=BC=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EB=B3=84=20=ED=95=98=EC=9C=84=20=ED=8F=B4=EB=8D=94=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토스트 컨테이너를 home/_containers/toast/ 하위로 이동했습니다 - QueryProvider를 providers/query/ 하위로 이동했습니다 --- .../home/_containers/{ => toast}/TagLimitToastContainer.tsx | 0 .../home/_containers/{ => toast}/TodoLimitToastContainer.tsx | 0 apps/timo-web/app/[locale]/layout.tsx | 2 +- apps/timo-web/providers/{ => query}/QueryProvider.tsx | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/{ => toast}/TagLimitToastContainer.tsx (100%) rename apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/{ => toast}/TodoLimitToastContainer.tsx (100%) rename apps/timo-web/providers/{ => query}/QueryProvider.tsx (100%) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/TagLimitToastContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/toast/TagLimitToastContainer.tsx similarity index 100% rename from apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/TagLimitToastContainer.tsx rename to apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/toast/TagLimitToastContainer.tsx diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/TodoLimitToastContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/toast/TodoLimitToastContainer.tsx similarity index 100% rename from apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/TodoLimitToastContainer.tsx rename to apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/toast/TodoLimitToastContainer.tsx diff --git a/apps/timo-web/app/[locale]/layout.tsx b/apps/timo-web/app/[locale]/layout.tsx index a24d698b..1f7276c4 100644 --- a/apps/timo-web/app/[locale]/layout.tsx +++ b/apps/timo-web/app/[locale]/layout.tsx @@ -6,7 +6,7 @@ import { hasLocale, NextIntlClientProvider } from "next-intl"; import type { Metadata } from "next"; import { routing } from "@/i18n/routing"; -import { QueryProvider } from "@/providers/QueryProvider"; +import { QueryProvider } from "@/providers/query/QueryProvider"; const pretendard = localFont({ src: "../../fonts/PretendardVariable.woff2", diff --git a/apps/timo-web/providers/QueryProvider.tsx b/apps/timo-web/providers/query/QueryProvider.tsx similarity index 100% rename from apps/timo-web/providers/QueryProvider.tsx rename to apps/timo-web/providers/query/QueryProvider.tsx From f775ed30e98506cff0e346a0fae0222e8e70c492 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 8 Jul 2026 22:53:52 +0900 Subject: [PATCH 7/9] =?UTF-8?q?comment(web):=20=ED=99=88=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=ED=95=A8=EC=88=98=EC=97=90=20JSDoc=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - date, day-of-week, home-view, todo-time 유틸 함수에 동작 설명을 추가했습니다 --- .../(with-time-sidebar)/home/_utils/date.ts | 30 +++++++++++++++++++ .../home/_utils/day-of-week.ts | 5 ++++ .../home/_utils/home-view.ts | 6 ++++ .../home/_utils/todo-time.ts | 5 ++++ 4 files changed, 46 insertions(+) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date.ts index aea3b821..2a99a16f 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date.ts @@ -1,5 +1,11 @@ const DATE_KEY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; +/** + * 날짜를 화면 표시용 텍스트로 변환한다. 매월 1일에는 "M.D" 형식을, + * 그 외에는 일(day)만 표시한다. + * @param date - 변환할 날짜 + * @returns 표시용 날짜 텍스트 (예: 1일 → "7.1", 그 외 → "15") + */ export const convertDateToDateText = (date: Date): string => { const day = date.getDate(); @@ -10,25 +16,44 @@ export const convertDateToDateText = (date: Date): string => { return `${day}`; }; +/** 주어진 날짜의 자정(00:00:00) 시각을 반환한다. */ const getStartOfDay = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), date.getDate()); +/** 오늘 날짜의 자정 시각을 반환한다. */ export const getToday = (): Date => getStartOfDay(new Date()); +/** + * 날짜에 일(day) 단위를 더한 새 날짜를 반환한다. + * @param date - 기준 날짜 + * @param amount - 더할 일수 (음수면 이전 날짜) + * @returns 계산된 새 날짜 객체 (원본은 변경하지 않음) + */ export const addDays = (date: Date, amount: number): Date => { const result = new Date(date); result.setDate(result.getDate() + amount); return result; }; +/** 두 날짜가 연/월/일 기준으로 같은 날인지 비교한다. */ export const isSameDate = (a: Date, b: Date): boolean => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +/** + * 시작 날짜부터 연속된 날짜 배열을 생성한다. + * @param start - 시작 날짜 + * @param length - 생성할 날짜 개수 + * @returns start부터 시작해 하루씩 증가하는 날짜 배열 + */ export const buildDateRange = (start: Date, length: number): Date[] => Array.from({ length }, (_, index) => addDays(start, index)); +/** + * 날짜를 "yyyy-MM-dd" 형식의 키 문자열로 변환한다. + * mock/API에서 날짜를 식별하는 키로 사용된다. + */ export const formatDateKey = (date: Date): string => { const year = date.getFullYear(); const month = `${date.getMonth() + 1}`.padStart(2, "0"); @@ -36,6 +61,11 @@ export const formatDateKey = (date: Date): string => { return `${year}-${month}-${day}`; }; +/** + * "yyyy-MM-dd" 형식의 키 문자열을 Date로 파싱한다. + * @param value - 파싱할 날짜 키 문자열 + * @returns 파싱된 Date, 형식이 맞지 않으면 null + */ export const parseDateKey = (value: string): Date | null => { const match = DATE_KEY_PATTERN.exec(value); diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/day-of-week.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/day-of-week.ts index 5616a526..e1fb2e44 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/day-of-week.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/day-of-week.ts @@ -2,5 +2,10 @@ import type { ApiDayOfWeek } from "@/app/[locale]/(main)/(with-time-sidebar)/hom import { getDayOfWeekKey } from "@/utils/get-day-of-week-key"; +/** + * 날짜를 홈 화면 API/i18n 키 형식의 요일 값으로 변환한다. + * @param date - 변환할 날짜 + * @returns "SUN"~"SAT" 형식의 요일 키 + */ export const getApiDayOfWeek = (date: Date): ApiDayOfWeek => getDayOfWeekKey(date) as ApiDayOfWeek; 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 index fe003898..ec557568 100644 --- 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 @@ -1,5 +1,11 @@ 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); diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-time.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-time.ts index a2a80b1f..527d41e5 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-time.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/todo-time.ts @@ -2,6 +2,11 @@ const SECONDS_PER_HOUR = 3600; const SECONDS_PER_MINUTE = 60; +/** + * 초 단위 duration을 "H:MM" 형식의 텍스트로 변환한다. + * @param durationSeconds - 변환할 시간(초) + * @returns "H:MM" 형식의 시간 텍스트 (예: 7200초 → "2:00") + */ export const convertDurationToTimeText = (durationSeconds: number): string => { const hours = Math.floor(durationSeconds / SECONDS_PER_HOUR); const minutes = Math.floor( From fdac27894abfbae044720e8cb9121bba28290bad Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 8 Jul 2026 22:55:07 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix(web):=20=ED=88=AC=EB=91=90=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=84=B8=EB=A1=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B9=A8=EC=A7=90=20=EC=88=98=EC=A0=95=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카드가 부모 컨테이너의 높이를 강제로 채우면서 여러 개일 때 서로 겹치던 문제를 수정했습니다 - 카드 높이를 콘텐츠 크기에 맞게 자연스럽게 계산되도록 변경했습니다 --- .../(with-time-sidebar)/home/_components/HomeTodoCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/HomeTodoCard.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/HomeTodoCard.tsx index d64be45e..77d4344a 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/HomeTodoCard.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/HomeTodoCard.tsx @@ -124,7 +124,7 @@ export const HomeTodoCard = ({ {...attributes} {...listeners} className={cn( - "border-timo-gray-500 flex size-full flex-col items-start gap-2 overflow-hidden rounded-[4px] border border-solid px-3.5 py-3", + "border-timo-gray-500 flex w-full shrink-0 flex-col items-start gap-2 overflow-hidden rounded-[4px] border border-solid px-3.5 py-3", isCompleted ? "bg-timo-gray-200" : "bg-white", )} > From cdfbfef8fd3f93234503dbfaa8d7356bfbeee436 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 8 Jul 2026 22:57:01 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat(web):=20=EC=9E=AC=EC=83=9D=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=A8=B8=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=98=A4=ED=94=88=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 타이머 사이드바 열림/탭 상태를 zustand 전역 스토어로 옮겨 깊은 트리와 공유했습니다 - 투두 카드의 재생 버튼을 눌러 타이머가 시작되면 사이드바가 열리고 Timer 탭으로 전환되도록 연동했습니다 --- .../_containers/WithTimeSidebarContainer.tsx | 13 +++++-------- .../home/_hooks/useHomeTodosByDate.ts | 10 ++++++++++ .../layout/sidebar/time/TimeSidebar.tsx | 11 +++++++---- apps/timo-web/stores/.gitkeep | 0 .../time-sidebar/useTimeSidebarStore.ts | 19 +++++++++++++++++++ 5 files changed, 41 insertions(+), 12 deletions(-) delete mode 100644 apps/timo-web/stores/.gitkeep create mode 100644 apps/timo-web/stores/time-sidebar/useTimeSidebarStore.ts diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/_containers/WithTimeSidebarContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/_containers/WithTimeSidebarContainer.tsx index 47d3a993..0d8d4e73 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/_containers/WithTimeSidebarContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/_containers/WithTimeSidebarContainer.tsx @@ -1,7 +1,6 @@ "use client"; import { cn } from "@repo/timo-design-system/utils"; -import { useState } from "react"; import { TIME_SIDEBAR_COLLAPSED_MARGIN_CLASS_NAME, @@ -11,6 +10,7 @@ import { import { TimeSidebar } from "@/components/layout/sidebar/time/TimeSidebar"; import { ROUTES } from "@/constants/routes"; import { usePathname } from "@/i18n/navigation"; +import { useTimeSidebarStore } from "@/stores/time-sidebar/useTimeSidebarStore"; interface WithTimeSidebarContainerProps { children: React.ReactNode; @@ -19,7 +19,8 @@ interface WithTimeSidebarContainerProps { export const WithTimeSidebarContainer = ({ children, }: WithTimeSidebarContainerProps) => { - const [isOpen, setIsOpen] = useState(true); + const isOpen = useTimeSidebarStore((state) => state.isOpen); + const toggleOpen = useTimeSidebarStore((state) => state.toggleOpen); const pathname = usePathname(); @@ -29,7 +30,7 @@ export const WithTimeSidebarContainer = ({ <>
- setIsOpen((prev) => !prev)} - /> + ); }; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodosByDate.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodosByDate.ts index c9e3c0c3..bfc00d83 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodosByDate.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/useHomeTodosByDate.ts @@ -7,9 +7,11 @@ import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types 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"; export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { const [todosByDate, setTodosByDate] = useState>({}); + const openTimerPanel = useTimeSidebarStore((state) => state.openTimerPanel); useEffect(() => { setTodosByDate( @@ -41,10 +43,18 @@ export const useHomeTodosByDate = (apiDays: HomeViewDay[]) => { const handleTogglePlay = (dateKey: string, todoId: number) => { // TODO: API + const willRun = + todosByDate[dateKey]?.find((todo) => todo.todoId === todoId) + ?.timerStatus !== "RUNNING"; + updateTodo(dateKey, todoId, (todo) => ({ ...todo, timerStatus: todo.timerStatus === "RUNNING" ? "STOPPED" : "RUNNING", })); + + if (willRun) { + openTimerPanel(); + } }; const handleToggleSubtaskCompleted = ( diff --git a/apps/timo-web/components/layout/sidebar/time/TimeSidebar.tsx b/apps/timo-web/components/layout/sidebar/time/TimeSidebar.tsx index c77fb616..3c46d9d3 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimeSidebar.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimeSidebar.tsx @@ -2,7 +2,7 @@ import { TogglePanel } from "@repo/timo-design-system/ui"; import { cn } from "@repo/timo-design-system/utils"; -import { useId, useState } from "react"; +import { useId } from "react"; import { TIME_SIDEBAR_COLLAPSED_WIDTH_CLASS_NAME, @@ -12,8 +12,10 @@ import { import { TimeboxPanel } from "@/components/layout/sidebar/time/TimeboxPanel"; import { TimerPanel } from "@/components/layout/sidebar/time/TimerPanel"; import { TimeSidebarHeader } from "@/components/layout/sidebar/time/TimeSidebarHeader"; - -type TimeSidebarTab = "timebox" | "timer"; +import { + type TimeSidebarTab, + useTimeSidebarStore, +} from "@/stores/time-sidebar/useTimeSidebarStore"; export interface TimeSidebarProps { size?: TimeSidebarSize; @@ -27,7 +29,8 @@ export const TimeSidebar = ({ onToggleCollapse, }: TimeSidebarProps) => { const id = useId(); - const [activeTab, setActiveTab] = useState("timebox"); + const activeTab = useTimeSidebarStore((state) => state.activeTab); + const setActiveTab = useTimeSidebarStore((state) => state.setActiveTab); const timeboxPanelId = `${id}-timebox-panel`; const timerPanelId = `${id}-timer-panel`; diff --git a/apps/timo-web/stores/.gitkeep b/apps/timo-web/stores/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/timo-web/stores/time-sidebar/useTimeSidebarStore.ts b/apps/timo-web/stores/time-sidebar/useTimeSidebarStore.ts new file mode 100644 index 00000000..b19ed35b --- /dev/null +++ b/apps/timo-web/stores/time-sidebar/useTimeSidebarStore.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; + +export type TimeSidebarTab = "timebox" | "timer"; + +interface TimeSidebarState { + isOpen: boolean; + activeTab: TimeSidebarTab; + toggleOpen: () => void; + setActiveTab: (tab: TimeSidebarTab) => void; + openTimerPanel: () => void; +} + +export const useTimeSidebarStore = create((set) => ({ + isOpen: true, + activeTab: "timebox", + toggleOpen: () => set((state) => ({ isOpen: !state.isOpen })), + setActiveTab: (tab) => set({ activeTab: tab }), + openTimerPanel: () => set({ isOpen: true, activeTab: "timer" }), +}));