-
Notifications
You must be signed in to change notification settings - Fork 0
[FEAT] 오늘 화면 TodayTodoCard 컴포넌트 구현 #106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 28 commits
14895fb
f270c93
ac806e3
9e431b9
6c5811d
53cecbc
fbbca26
5e12005
d9678f8
2718ed2
f1d1c8c
d595fbd
e5018a8
787973d
6ed513d
0080c32
6d5a644
3183d39
1ed7a9b
7ed3d41
e0b80cb
281652d
8f2c8ce
49f1a39
037b6c7
7ea2637
6da9956
50cc380
07a5b88
7bcc57c
cb712b0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: string) => 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: string) => { | ||
| setSubTodos((prev) => | ||
| prev.map((s) => (s.id === id ? { ...s, isDone: !s.isDone } : s)), | ||
| ); | ||
| onSubTodoCheck?.(id); | ||
| }; | ||
|
|
||
| return ( | ||
| <TodayTodoCard | ||
| title={title} | ||
| isDone={isDone} | ||
| isDimmed={isDimmed} | ||
| isPlaying={isPlaying} | ||
| icon={icon} | ||
| onIconClick={onIconClick} | ||
| subTodos={subTodos} | ||
| toolbar={toolbar} | ||
| onCheck={handleCheck} | ||
| onPlay={handlePlay} | ||
| onDelete={onDelete ?? (() => {})} | ||
| onSubTodoCheck={handleSubTodoCheck} | ||
| onMouseEnter={() => setIsHovered(true)} | ||
| onMouseLeave={() => setIsHovered(false)} | ||
| /> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| "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<TodoMock[]>(todayTodoMocks); | ||
| const [runningTodoId, setRunningTodoId] = useState<number | null>( | ||
| todayTodoMocks.find((t) => t.timerStatus === "RUNNING")?.todoId ?? null, | ||
| ); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. runningTodoId가 todos의 timerStatus와 별도 state로 관리되고 있어서, todo가 삭제/완료될 때마다 두 state를 수동으로 동기화해주고 계신 것 같아요! todos.find(t => t.timerStatus === "RUNNING")?.todoId로 파생시키면 동기화 코드 자체가 필요 없어질 것 같은데, 혹시 Zustand 연결 시점에 구조가 또 바뀔 예정이라 지금은 편의상 분리해두신 걸까요?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. runningTodoId를 별도 state로 두면 삭제/완료 시마다 수동으로 동기화해야하네요,, todos에서 파생시키는 방식으로 수정했습니다! Zustand 연결 시점에도 timerStatus를 store에서 관리하면 동일하게 파생 가능할 것 같네요 |
||
|
|
||
| const handlePlay = (todoId: number) => { | ||
| // TODO: API | ||
| setRunningTodoId((prev) => (prev === todoId ? null : todoId)); | ||
| }; | ||
|
|
||
| const handleCheck = (todoId: number) => { | ||
| // TODO: API | ||
| setTodos((prev) => | ||
| prev.map((t) => | ||
| t.todoId === todoId ? { ...t, completed: !t.completed } : t, | ||
| ), | ||
| ); | ||
| if (runningTodoId === todoId) setRunningTodoId(null); | ||
| }; | ||
|
|
||
| const handleDelete = (todoId: number) => { | ||
| // TODO: API | ||
| setTodos((prev) => prev.filter((t) => t.todoId !== todoId)); | ||
| if (runningTodoId === todoId) setRunningTodoId(null); | ||
| }; | ||
|
|
||
| const handleSubTodoCheck = (todoId: number, subtaskId: string) => { | ||
| // TODO: API | ||
| setTodos((prev) => | ||
| prev.map((t) => | ||
| t.todoId === todoId | ||
| ? { | ||
| ...t, | ||
| subtasks: t.subtasks.map((s) => | ||
| s.subtaskId === Number(subtaskId) | ||
| ? { ...s, completed: !s.completed } | ||
| : s, | ||
| ), | ||
| } | ||
| : t, | ||
| ), | ||
| ); | ||
| }; | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. subtaskId가 number → string → number로 왕복 변환되고 있는데, SubTodo.id를 number로 맞추면 변환 로직 자체를 없앨 수 있을 것 같아요-! 지금은 안전하지만 나중에 id 형식이 바뀌면 Number() 변환에서 조용히 실패할 수 있어서, 미리 타입을 통일해두면 어떨까요?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. id: number로 통일하고 변환 로직을 제거했습니다!GOod |
||
| return ( | ||
| <div className="flex flex-col gap-2"> | ||
| {todos.map((todo) => ( | ||
| <TodayTodoCardContainer | ||
| key={todo.todoId} | ||
| title={todo.title} | ||
| isDone={todo.completed} | ||
| timerStatus={runningTodoId === todo.todoId ? "RUNNING" : "STOPPED"} | ||
| toolbar={{ | ||
| date: formatDate(todo.date), | ||
| time: convertDurationToTimeText(todo.durationSeconds), | ||
| priority: PRIORITY_MAP[todo.priority], | ||
| tag: todo.tag?.name, | ||
| hasMemo: todo.hasMemo, | ||
| hasRepeat: todo.isRepeated, | ||
| }} | ||
| subTodos={todo.subtasks.map((s) => ({ | ||
| id: String(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)} | ||
| /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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[]; | ||
| } | ||
|
Comment on lines
+1
to
+26
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 인터페이스들 나중에 API 연동 시에는 zod 도입해 봅시다! |
||
|
|
||
| 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: [], | ||
| }, | ||
| ]; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <TodayHeaderContainer />; | ||
| return ( | ||
| <section> | ||
| <TodayHeaderContainer /> | ||
| <div className="p-4"> | ||
| <TodayTodoListContainer /> | ||
| </div> | ||
| </section> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🔵 Trivial | 💤 Low value
🧩 Analysis chain
🏁 Script executed:
Repository: Team-Timo/Timo-client
Length of output: 5339
🏁 Script executed:
Repository: Team-Timo/Timo-client
Length of output: 7540
🏁 Script executed:
Repository: Team-Timo/Timo-client
Length of output: 1645
onPlay이름을 더 중립적으로 바꾸세요.구조는 깔끔한데, 이름만 살짝 더 맞추면 읽기 쉬워져요. 지금은 재생 버튼 클릭과 체크로 인한 자동 정지를 같은 콜백으로 전달해서,
onPlay보다onTimerToggle같은 이름이 의도를 더 잘 드러냅니다. 정지 동작을 따로 다뤄야 하면onStop을 분리하는 쪽도 좋습니다.React 이벤트 패턴: https://react.dev/learn/responding-to-events
🤖 Prompt for AI Agents