Skip to content

Commit 32cb8ce

Browse files
authored
Merge pull request #116 from Team-Timo/feat/web/109-focus-view
[FEAT] 집중 모드(Focus) 타이머·태스크 상세 화면 구현
2 parents 694b312 + 45e5e41 commit 32cb8ce

7 files changed

Lines changed: 290 additions & 2 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {
2+
CalendarOnIcon,
3+
ClockOnIcon,
4+
PlayIcon,
5+
StopIcon,
6+
} from "@repo/timo-design-system/icons";
7+
import { Checkbox, PlayButton } from "@repo/timo-design-system/ui";
8+
import { cn } from "@repo/timo-design-system/utils";
9+
10+
import type { FocusTaskSubtask } from "@/app/[locale]/(main)/focus/_types/task-type";
11+
12+
export interface FocusTaskItemProps {
13+
dayNumber: string;
14+
dayOfWeek: string;
15+
title: string;
16+
completed: boolean;
17+
dateText: string;
18+
durationText: string;
19+
isRunning: boolean;
20+
subtasks: FocusTaskSubtask[];
21+
memo?: string;
22+
onToggleCompleted: (completed: boolean) => void;
23+
onTogglePlay: () => void;
24+
onToggleSubtaskCompleted: (subtaskId: number, completed: boolean) => void;
25+
}
26+
27+
export const FocusTaskItem = ({
28+
dayNumber,
29+
dayOfWeek,
30+
title,
31+
completed,
32+
dateText,
33+
durationText,
34+
isRunning,
35+
subtasks,
36+
memo,
37+
onToggleCompleted,
38+
onTogglePlay,
39+
onToggleSubtaskCompleted,
40+
}: FocusTaskItemProps) => {
41+
return (
42+
<section className="flex w-full min-w-80 flex-col items-start gap-5 px-[34.5px] pb-8">
43+
<div className="text-timo-gray-900 flex flex-col items-start">
44+
<p className="typo-headline-b-30">{dayNumber}</p>
45+
<p className="typo-headline-m-14">{dayOfWeek}</p>
46+
</div>
47+
48+
<div className="flex w-full items-center gap-2.5">
49+
<Checkbox checked={completed} onChange={onToggleCompleted} />
50+
<p className="typo-headline-b-22 text-timo-black min-w-0 flex-1 wrap-break-word">
51+
{title}
52+
</p>
53+
</div>
54+
55+
<div className="flex w-full items-center justify-between">
56+
<div className="flex items-center gap-2">
57+
<div className="flex items-center gap-0.5">
58+
<CalendarOnIcon width={22} height={22} />
59+
<span className="typo-caption-r-10 text-timo-gray-900 whitespace-nowrap">
60+
{dateText}
61+
</span>
62+
</div>
63+
64+
<div className="flex items-center gap-0.5">
65+
<ClockOnIcon width={22} height={22} />
66+
<span className="typo-caption-r-10 text-timo-gray-900 w-9 text-center whitespace-nowrap">
67+
{durationText}
68+
</span>
69+
</div>
70+
</div>
71+
72+
<PlayButton
73+
variant={isRunning ? "stop" : "play"}
74+
size="lg"
75+
onClick={onTogglePlay}
76+
>
77+
{isRunning ? (
78+
<StopIcon width={24} height={24} />
79+
) : (
80+
<PlayIcon width={24} height={24} />
81+
)}
82+
</PlayButton>
83+
</div>
84+
85+
<div className="border-timo-gray-500 flex min-h-26 w-full min-w-0 flex-col gap-3 border-t pt-3">
86+
{subtasks.map((subtask) => (
87+
<div key={subtask.subtaskId} className="flex items-center gap-2">
88+
<Checkbox
89+
checked={subtask.completed}
90+
onChange={(checked) =>
91+
onToggleSubtaskCompleted(subtask.subtaskId, checked)
92+
}
93+
/>
94+
<p
95+
className={cn(
96+
"typo-body-r-12 min-w-0 flex-1 wrap-break-word",
97+
subtask.completed ? "text-timo-gray-700" : "text-timo-black",
98+
)}
99+
>
100+
{subtask.content}
101+
</p>
102+
</div>
103+
))}
104+
105+
{memo && (
106+
<p className="typo-body-r-12 text-timo-gray-800 min-w-0 wrap-break-word">
107+
{memo}
108+
</p>
109+
)}
110+
</div>
111+
</section>
112+
);
113+
};
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"use client";
2+
3+
import { TimerOnIcon } from "@repo/timo-design-system/icons";
4+
import { useState } from "react";
5+
6+
import type { FocusTask } from "@/app/[locale]/(main)/focus/_types/task-type";
7+
8+
import { FocusTaskItem } from "@/app/[locale]/(main)/focus/_components/FocusTaskItem";
9+
import { FocusHeaderContainer } from "@/app/[locale]/(main)/focus/_containers/FocusHeaderContainer";
10+
import { focusTaskMock } from "@/app/[locale]/(main)/focus/_mocks/task-mock";
11+
import {
12+
convertDateToBadgeText,
13+
convertDateToDayNumberText,
14+
convertDateToDayOfWeekText,
15+
} from "@/app/[locale]/(main)/focus/_utils/date";
16+
import { convertDurationToTimeText } from "@/app/[locale]/(main)/focus/_utils/duration";
17+
import { Timer } from "@/components/timer/Timer";
18+
import { TimerControls } from "@/components/timer/TimerControls";
19+
20+
export const FocusSessionContainer = () => {
21+
const [task, setTask] = useState<FocusTask>(focusTaskMock);
22+
const today = new Date();
23+
24+
const handleTogglePlay = () => {
25+
// TODO: API
26+
setTask((prev) => ({ ...prev, isRunning: !prev.isRunning }));
27+
};
28+
29+
const handleEnd = () => {
30+
// TODO: API
31+
setTask((prev) => ({
32+
...prev,
33+
isRunning: false,
34+
completed: true,
35+
subtasks: prev.subtasks.map((subtask) => ({
36+
...subtask,
37+
completed: true,
38+
})),
39+
}));
40+
};
41+
42+
const handleAddTime = () => {};
43+
44+
const handleToggleCompleted = (completed: boolean) => {
45+
// TODO: API
46+
setTask((prev) => ({
47+
...prev,
48+
completed,
49+
isRunning: completed ? false : prev.isRunning,
50+
subtasks: prev.subtasks.map((subtask) => ({ ...subtask, completed })),
51+
}));
52+
};
53+
54+
const handleToggleSubtaskCompleted = (
55+
subtaskId: number,
56+
completed: boolean,
57+
) => {
58+
// TODO: API
59+
setTask((prev) => ({
60+
...prev,
61+
subtasks: prev.subtasks.map((subtask) =>
62+
subtask.subtaskId === subtaskId ? { ...subtask, completed } : subtask,
63+
),
64+
}));
65+
};
66+
67+
return (
68+
<div className="flex h-full overflow-x-auto">
69+
<div className="flex flex-1 flex-col gap-18">
70+
<FocusHeaderContainer />
71+
<FocusTaskItem
72+
dayNumber={convertDateToDayNumberText(today)}
73+
dayOfWeek={convertDateToDayOfWeekText(today)}
74+
title={task.title}
75+
completed={task.completed}
76+
dateText={convertDateToBadgeText(task.scheduledDate)}
77+
durationText={convertDurationToTimeText(task.durationSeconds)}
78+
isRunning={task.isRunning}
79+
subtasks={task.subtasks}
80+
memo={task.memo}
81+
onToggleCompleted={handleToggleCompleted}
82+
onTogglePlay={handleTogglePlay}
83+
onToggleSubtaskCompleted={handleToggleSubtaskCompleted}
84+
/>
85+
</div>
86+
87+
<section className="border-timo-gray-500 flex h-full w-136.5 shrink-0 items-center border-l bg-white">
88+
<div className="flex w-full flex-col items-center gap-11.25">
89+
<Timer
90+
icon={<TimerOnIcon />}
91+
time="10:30"
92+
plannedLabel="12M"
93+
progress={70}
94+
size="lg"
95+
/>
96+
97+
<TimerControls
98+
isRunning={task.isRunning}
99+
onTogglePlay={handleTogglePlay}
100+
onEnd={handleEnd}
101+
onAddTime={handleAddTime}
102+
/>
103+
</div>
104+
</section>
105+
</div>
106+
);
107+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { FocusTask } from "@/app/[locale]/(main)/focus/_types/task-type";
2+
3+
export const focusTaskMock: FocusTask = {
4+
taskId: 1,
5+
title: "앱잼 1차 과제 제출",
6+
completed: false,
7+
scheduledDate: new Date(2026, 6, 1),
8+
durationSeconds: 7200,
9+
isRunning: false,
10+
subtasks: [
11+
{ subtaskId: 1, content: "기획서 작성하기", completed: false },
12+
{ subtaskId: 2, content: "와이어프레임 그리기", completed: true },
13+
],
14+
memo: "회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기",
15+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export interface FocusTaskSubtask {
2+
subtaskId: number;
3+
content: string;
4+
completed: boolean;
5+
}
6+
7+
export interface FocusTask {
8+
taskId: number;
9+
title: string;
10+
completed: boolean;
11+
scheduledDate: Date;
12+
durationSeconds: number;
13+
isRunning: boolean;
14+
subtasks: FocusTaskSubtask[];
15+
memo?: string;
16+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const DAY_OF_WEEK_LABELS = [
2+
"일요일",
3+
"월요일",
4+
"화요일",
5+
"수요일",
6+
"목요일",
7+
"금요일",
8+
"토요일",
9+
];
10+
11+
export const convertDateToDayNumberText = (date: Date): string => {
12+
return `${date.getDate()}`;
13+
};
14+
15+
export const convertDateToDayOfWeekText = (date: Date): string => {
16+
return DAY_OF_WEEK_LABELS[date.getDay()] ?? "";
17+
};
18+
19+
export const convertDateToBadgeText = (date: Date): string => {
20+
const year = `${date.getFullYear()}`.slice(-2);
21+
const month = `${date.getMonth() + 1}`.padStart(2, "0");
22+
const day = `${date.getDate()}`.padStart(2, "0");
23+
24+
return `${year}.${month}.${day}`;
25+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const SECONDS_PER_HOUR = 3600;
2+
3+
const SECONDS_PER_MINUTE = 60;
4+
5+
export const convertDurationToTimeText = (durationSeconds: number): string => {
6+
const hours = Math.floor(durationSeconds / SECONDS_PER_HOUR);
7+
const minutes = Math.floor(
8+
(durationSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
9+
);
10+
11+
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
12+
};
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { FocusHeaderContainer } from "@/app/[locale]/(main)/focus/_containers/FocusHeaderContainer";
1+
import { FocusSessionContainer } from "@/app/[locale]/(main)/focus/_containers/FocusSessionContainer";
22

33
export default function FocusPage() {
4-
return <FocusHeaderContainer />;
4+
return <FocusSessionContainer />;
55
}

0 commit comments

Comments
 (0)