diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoCardContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoCardContainer.tsx new file mode 100644 index 00000000..9618cb1c --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoCardContainer.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import type { ReactNode } from "react"; + +import { + TodayTodoCard, + type SubTodo, + type TodayTodoCardToolbar, +} from "@/app/[locale]/(main)/today/_components/TodayTodoCard"; + +export interface TodayTodoCardContainerProps { + title: string; + isDone: boolean; + subTodos: SubTodo[]; + toolbar: TodayTodoCardToolbar; + timerStatus: "RUNNING" | "PAUSED" | "STOPPED"; + icon?: ReactNode; + onIconClick?: () => void; + onCheck?: () => void; + onPlay?: () => void; + onDelete?: () => void; + onSubTodoCheck?: (id: number) => void; +} + +export const TodayTodoCardContainer = ({ + title, + isDone: initialIsDone, + icon, + onIconClick, + subTodos: initialSubTodos, + toolbar, + timerStatus, + onCheck, + onPlay, + onDelete, + onSubTodoCheck, +}: TodayTodoCardContainerProps) => { + const [isHovered, setIsHovered] = useState(false); + const [isPlaying, setIsPlaying] = useState(timerStatus === "RUNNING"); + const [isDone, setIsDone] = useState(initialIsDone); + const [subTodos, setSubTodos] = useState(initialSubTodos); + + useEffect(() => { + setIsPlaying(timerStatus === "RUNNING"); + }, [timerStatus]); + + const isDimmed = isDone && !isHovered; + + const handleCheck = () => { + const next = !isDone; + setIsDone(next); + if (next) { + setSubTodos((prev) => prev.map((s) => ({ ...s, isDone: true }))); + if (isPlaying) { + setIsPlaying(false); + onPlay?.(); + } + } + onCheck?.(); + }; + + const handlePlay = () => { + if (!isDone) { + setIsPlaying((prev) => !prev); + onPlay?.(); + } + }; + + const handleSubTodoCheck = (id: number) => { + setSubTodos((prev) => + prev.map((s) => (s.id === id ? { ...s, isDone: !s.isDone } : s)), + ); + onSubTodoCheck?.(id); + }; + + return ( + {})} + onSubTodoCheck={handleSubTodoCheck} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + /> + ); +}; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx new file mode 100644 index 00000000..e6b69198 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useState } from "react"; + +import type { TodoMock } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_mocks/today-todo-mock"; + +import { TodayTodoCardContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoCardContainer"; +import { todayTodoMocks } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_mocks/today-todo-mock"; +import { convertDurationToTimeText } from "@/utils/convert-duration-to-time-text"; +import { formatDate } from "@/utils/format-date"; + +const PRIORITY_MAP = { + URGENT: "urgent", + HIGH: "high", + MEDIUM: "medium", + LOW: "low", +} as const; + +export const TodayTodoListContainer = () => { + const [todos, setTodos] = useState(todayTodoMocks); + const runningTodoId = + todos.find((t) => t.timerStatus === "RUNNING")?.todoId ?? null; + + const handlePlay = (todoId: number) => { + // TODO: API + setTodos((prev) => + prev.map((t) => ({ + ...t, + timerStatus: + t.todoId === todoId && t.timerStatus !== "RUNNING" + ? "RUNNING" + : "STOPPED", + })), + ); + }; + + const handleCheck = (todoId: number) => { + // TODO: API + setTodos((prev) => + prev.map((t) => + t.todoId === todoId + ? { ...t, completed: !t.completed, timerStatus: "STOPPED" } + : t, + ), + ); + }; + + const handleDelete = (todoId: number) => { + // TODO: API + setTodos((prev) => prev.filter((t) => t.todoId !== todoId)); + }; + + const handleSubTodoCheck = (todoId: number, subtaskId: number) => { + // TODO: API + setTodos((prev) => + prev.map((t) => + t.todoId === todoId + ? { + ...t, + subtasks: t.subtasks.map((s) => + s.subtaskId === subtaskId + ? { ...s, completed: !s.completed } + : s, + ), + } + : t, + ), + ); + }; + + return ( +
+ {todos.map((todo) => ( + ({ + id: s.subtaskId, + text: s.content, + isDone: s.completed, + }))} + onPlay={() => handlePlay(todo.todoId)} + onCheck={() => handleCheck(todo.todoId)} + onDelete={() => handleDelete(todo.todoId)} + onSubTodoCheck={(id) => handleSubTodoCheck(todo.todoId, id)} + /> + ))} +
+ ); +}; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_mocks/today-todo-mock.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_mocks/today-todo-mock.ts new file mode 100644 index 00000000..2b96261b --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_mocks/today-todo-mock.ts @@ -0,0 +1,77 @@ +interface TodoTag { + tagId: number; + name: string; +} + +interface TodoSubtask { + subtaskId: number; + content: string; + completed: boolean; +} + +export interface TodoMock { + todoId: number; + icon: string; + title: string; + completed: boolean; + date: string; + durationSeconds: number; + priority: "URGENT" | "HIGH" | "MEDIUM" | "LOW"; + tag: TodoTag | null; + hasMemo: boolean; + isRepeated: boolean; + timerStatus: "RUNNING" | "PAUSED" | "STOPPED"; + sortOrder: number; + subtasks: TodoSubtask[]; +} + +export const todayTodoMocks: TodoMock[] = [ + { + todoId: 1, + icon: "ICON_1", + title: "디자인 시스템 컴포넌트 정리하기", + completed: false, + date: "2026-07-09", + durationSeconds: 36000, + priority: "URGENT", + tag: { tagId: 1, name: "작업" }, + hasMemo: true, + isRepeated: false, + timerStatus: "STOPPED", + sortOrder: 0, + subtasks: [ + { subtaskId: 1, content: "색상 토큰 정리", completed: true }, + { subtaskId: 2, content: "타이포그래피 스펙 문서화", completed: false }, + ], + }, + { + todoId: 2, + icon: "ICON_2", + title: "완료된 할 일 예시", + completed: true, + date: "2026-07-09", + durationSeconds: 3600, + priority: "MEDIUM", + tag: { tagId: 2, name: "완료" }, + hasMemo: false, + isRepeated: false, + timerStatus: "STOPPED", + sortOrder: 1, + subtasks: [], + }, + { + todoId: 3, + icon: "ICON_3", + title: "서브투두 없는 단순 카드", + completed: false, + date: "2026-07-09", + durationSeconds: 1800, + priority: "HIGH", + tag: null, + hasMemo: true, + isRepeated: true, + timerStatus: "STOPPED", + sortOrder: 2, + subtasks: [], + }, +]; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/page.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/page.tsx index 6cbd83d6..6e632bd5 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/page.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/page.tsx @@ -1,5 +1,13 @@ import { TodayHeaderContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayHeaderContainer"; +import { TodayTodoListContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer"; export default function TodayPage() { - return ; + return ( +
+ +
+ +
+
+ ); } diff --git a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx index 24b12380..aa84a616 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx @@ -13,9 +13,9 @@ import { convertDateToDayNumberText, convertDateToDayOfWeekText, } from "@/app/[locale]/(main)/focus/_utils/date"; -import { convertDurationToTimeText } from "@/app/[locale]/(main)/focus/_utils/duration"; import { Timer } from "@/components/timer/Timer"; import { TimerControls } from "@/components/timer/TimerControls"; +import { convertDurationToTimeText } from "@/utils/convert-duration-to-time-text"; export const FocusSessionContainer = () => { const [task, setTask] = useState(focusTaskMock); diff --git a/apps/timo-web/app/[locale]/(main)/today/_components/TodayTodoCard.tsx b/apps/timo-web/app/[locale]/(main)/today/_components/TodayTodoCard.tsx new file mode 100644 index 00000000..ed1d2331 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/today/_components/TodayTodoCard.tsx @@ -0,0 +1,157 @@ +import { + PlayDisabledIcon, + PlayIcon, + StopIcon, +} from "@repo/timo-design-system/icons"; +import { + Checkbox, + PlayButton, + PriorityIcon, +} from "@repo/timo-design-system/ui"; +import { cn } from "@repo/timo-design-system/utils"; + +import type { ComponentProps, ReactNode } from "react"; + +import { CreateTodoToolbar } from "@/components/CreateTodoToolbar"; + +type PriorityTypes = ComponentProps["priority"]; + +const CARD_STYLE = { + active: { + card: "bg-white", + title: "text-timo-gray-900", + subText: "text-timo-gray-700", + }, + done: { + card: "bg-timo-gray-200", + title: "text-timo-gray-700", + subText: "text-timo-gray-700", + }, +} as const; + +export interface SubTodo { + id: number; + text: string; + isDone?: boolean; +} + +export interface TodayTodoCardToolbar { + date: string; + time: string; + priority: PriorityTypes; + tag?: string; + hasMemo: boolean; + hasRepeat: boolean; +} + +export interface TodayTodoCardProps { + title: string; + isDone: boolean; + isDimmed: boolean; + isPlaying: boolean; + icon?: ReactNode; + onIconClick?: () => void; + subTodos: SubTodo[]; + toolbar: TodayTodoCardToolbar; + onCheck: () => void; + onPlay: () => void; + onDelete: () => void; + onSubTodoCheck: (id: number) => void; + onMouseEnter: () => void; + onMouseLeave: () => void; +} + +export const TodayTodoCard = ({ + title, + isDone, + isDimmed, + isPlaying, + icon, + onIconClick, + subTodos, + toolbar, + onCheck, + onPlay, + onDelete, + onSubTodoCheck, + onMouseEnter, + onMouseLeave, +}: TodayTodoCardProps) => { + const style = CARD_STYLE[isDimmed ? "done" : "active"]; + + return ( +
+
+
+ onCheck()} /> + {icon && ( + + )} + + {title} + +
+ + {isDone ? ( + + ) : isPlaying ? ( + + ) : ( + + )} + +
+ + {subTodos.length > 0 && ( +
    + {subTodos.map((sub) => ( +
  • + onSubTodoCheck(sub.id)} + /> + + {sub.text} + +
  • + ))} +
+ )} + +
+ +
+
+ ); +}; diff --git a/apps/timo-web/components/CreateTodoToolbar.tsx b/apps/timo-web/components/CreateTodoToolbar.tsx new file mode 100644 index 00000000..a6c0523c --- /dev/null +++ b/apps/timo-web/components/CreateTodoToolbar.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { + CalendarBlueIcon, + CalendarDisableIcon, + CalendarOnIcon, + ClockBlueIcon, + ClockDisableIcon, + ClockOnIcon, + MemoBlueIcon, + MemoDisableIcon, + MemoOnIcon, + RepeatBlueIcon, + RepeatTodoDisableIcon, + RepeatTodoOnIcon, + TrashBlueIcon, + TrashDisableIcon, + TrashOnIcon, +} from "@repo/timo-design-system/icons"; +import { PriorityIcon, TagIcon } from "@repo/timo-design-system/ui"; +import { cn } from "@repo/timo-design-system/utils"; + +import type { ComponentProps } from "react"; + +type Priority = ComponentProps["priority"]; + +type ActiveItem = + | "date" + | "time" + | "priority" + | "tag" + | "memo" + | "repeat" + | "delete"; + +export interface CreateTodoToolbarProps { + date: string; + time: string; + priority: Priority; + tag?: string; + hasMemo: boolean; + hasRepeat?: boolean; + hasDelete?: boolean; + isDimmed?: boolean; + activeItem?: ActiveItem; + onDateClick?: () => void; + onTimeClick?: () => void; + onPriorityClick?: () => void; + onTagClick?: () => void; + onMemoClick?: () => void; + onRepeatClick?: () => void; + onDeleteClick?: () => void; +} + +export const CreateTodoToolbar = ({ + date, + time, + priority, + tag, + hasMemo, + hasRepeat, + hasDelete, + isDimmed, + activeItem, + onDateClick, + onTimeClick, + onPriorityClick, + onTagClick, + onMemoClick, + onRepeatClick, + onDeleteClick, +}: CreateTodoToolbarProps) => { + return ( +
+ + + + + + + + + + + + + +
+ ); +}; diff --git a/apps/timo-web/utils/convert-duration-to-time-text.ts b/apps/timo-web/utils/convert-duration-to-time-text.ts new file mode 100644 index 00000000..f087b0ad --- /dev/null +++ b/apps/timo-web/utils/convert-duration-to-time-text.ts @@ -0,0 +1,22 @@ +const SECONDS_PER_HOUR = 3600; + +const SECONDS_PER_MINUTE = 60; + +/** + * 초(seconds) 단위의 지속 시간을 "HH:MM" 형식의 문자열로 변환합니다. + * + * @param durationSeconds - 변환할 지속 시간 (초 단위, 0 이상의 정수) + * @returns "00:00" 형식의 시간 문자열 (시·분 모두 2자리 패딩) + * + * @example + * convertDurationToTimeText(3661) // "01:01" + * convertDurationToTimeText(0) // "00:00" + */ +export const convertDurationToTimeText = (durationSeconds: number): string => { + const hours = Math.floor(durationSeconds / SECONDS_PER_HOUR); + const minutes = Math.floor( + (durationSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE, + ); + + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; +}; diff --git a/apps/timo-web/utils/format-date.ts b/apps/timo-web/utils/format-date.ts new file mode 100644 index 00000000..a979865a --- /dev/null +++ b/apps/timo-web/utils/format-date.ts @@ -0,0 +1,14 @@ +/** + * ISO 8601 날짜 문자열을 "M/D" 형식으로 변환합니다. + * + * @param isoDate - ISO 8601 형식의 날짜 문자열 (예: "2025-07-10T00:00:00Z") + * @returns "월/일" 형식의 날짜 문자열 (예: "7/10") + * + * @example + * formatDate("2025-07-10T00:00:00Z") // "7/10" + * formatDate("2025-01-01T00:00:00Z") // "1/1" + */ +export const formatDate = (isoDate: string): string => { + const date = new Date(isoDate); + return `${date.getMonth() + 1}/${date.getDate()}`; +}; diff --git a/packages/timo-design-system/src/components/button/modal-button/ModalButton.stories.tsx b/packages/timo-design-system/src/components/button/modal-button/ModalButton.stories.tsx index d85f11ea..bf575de2 100644 --- a/packages/timo-design-system/src/components/button/modal-button/ModalButton.stories.tsx +++ b/packages/timo-design-system/src/components/button/modal-button/ModalButton.stories.tsx @@ -34,7 +34,7 @@ export const AllStates: Story = { export const CustomWidth: Story = { parameters: { controls: { disable: true } }, render: () => ( -
+
돌아가기 diff --git a/packages/timo-design-system/src/components/priority/priority-icon/PriorityIcon.tsx b/packages/timo-design-system/src/components/priority/priority-icon/PriorityIcon.tsx index f4df4994..7f72cc96 100644 --- a/packages/timo-design-system/src/components/priority/priority-icon/PriorityIcon.tsx +++ b/packages/timo-design-system/src/components/priority/priority-icon/PriorityIcon.tsx @@ -14,7 +14,7 @@ const PRIORITY_COLOR: Record = { high: "bg-timo-orange", medium: "bg-timo-gray-600", low: "bg-timo-black", - Disable: "bg-timo-gray-500", + Disable: "bg-timo-gray-700", white: "bg-white", blue: "bg-timo-blue-300", }; diff --git a/packages/timo-design-system/src/components/tag/tag-icon/TagIcon.stories.tsx b/packages/timo-design-system/src/components/tag/tag-icon/TagIcon.stories.tsx index a9f07d3a..2a90cb2b 100644 --- a/packages/timo-design-system/src/components/tag/tag-icon/TagIcon.stories.tsx +++ b/packages/timo-design-system/src/components/tag/tag-icon/TagIcon.stories.tsx @@ -15,7 +15,7 @@ const meta = { }, variant: { control: "select", - options: ["default", "blue"], + options: ["disable", "default", "blue"], description: "태그 스타일 베리언트", }, }, @@ -24,6 +24,10 @@ const meta = { export default meta; type Story = StoryObj; +export const Disable: Story = { + args: { text: "태그", variant: "disable" }, +}; + export const Default: Story = { args: { text: "과제", variant: "default" }, }; diff --git a/packages/timo-design-system/src/components/tag/tag-icon/TagIcon.tsx b/packages/timo-design-system/src/components/tag/tag-icon/TagIcon.tsx index 6c8faa81..d81dedb8 100644 --- a/packages/timo-design-system/src/components/tag/tag-icon/TagIcon.tsx +++ b/packages/timo-design-system/src/components/tag/tag-icon/TagIcon.tsx @@ -1,12 +1,18 @@ import { cn } from "../../../lib"; -export type TagVariant = "default" | "blue"; +export type TagVariant = "disable" | "default" | "blue"; export interface TagIconProps { text: string; variant?: TagVariant; } +const TEXT_COLOR: Record = { + disable: "text-timo-gray-700", + default: "text-timo-gray-800", + blue: "text-white", +}; + export const TagIcon = ({ text, variant = "default" }: TagIconProps) => { const isBlue = variant === "blue"; @@ -20,7 +26,7 @@ export const TagIcon = ({ text, variant = "default" }: TagIconProps) => { {text} diff --git a/packages/timo-design-system/src/icons/source/calendar-blue.svg b/packages/timo-design-system/src/icons/source/calendar-blue.svg index dfa750e2..71aca311 100644 --- a/packages/timo-design-system/src/icons/source/calendar-blue.svg +++ b/packages/timo-design-system/src/icons/source/calendar-blue.svg @@ -12,7 +12,7 @@ - + diff --git a/packages/timo-design-system/src/icons/source/calendar-disable.svg b/packages/timo-design-system/src/icons/source/calendar-disable.svg index aa9aa1d0..93a3afed 100644 --- a/packages/timo-design-system/src/icons/source/calendar-disable.svg +++ b/packages/timo-design-system/src/icons/source/calendar-disable.svg @@ -12,7 +12,7 @@ - + diff --git a/packages/timo-design-system/src/icons/source/calendar-on.svg b/packages/timo-design-system/src/icons/source/calendar-on.svg index ec57b8d5..55c9c0e6 100644 --- a/packages/timo-design-system/src/icons/source/calendar-on.svg +++ b/packages/timo-design-system/src/icons/source/calendar-on.svg @@ -12,7 +12,7 @@ - + diff --git a/packages/timo-design-system/src/icons/source/hamburger-gray.svg b/packages/timo-design-system/src/icons/source/hamburger-gray.svg new file mode 100644 index 00000000..d77b9e97 --- /dev/null +++ b/packages/timo-design-system/src/icons/source/hamburger-gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/timo-design-system/src/icons/source/hamburger.svg b/packages/timo-design-system/src/icons/source/hamburger.svg new file mode 100644 index 00000000..21474438 --- /dev/null +++ b/packages/timo-design-system/src/icons/source/hamburger.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/timo-design-system/src/icons/source/memo-blue.svg b/packages/timo-design-system/src/icons/source/memo-blue.svg new file mode 100644 index 00000000..068dcffb --- /dev/null +++ b/packages/timo-design-system/src/icons/source/memo-blue.svg @@ -0,0 +1,3 @@ + + +