diff --git a/src/App.tsx b/src/App.tsx
index 77fb20a..14f592b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -22,6 +22,7 @@ import LicensePage from './pages/legal/LicensePage';
import MarketingPolicyPage from './pages/legal/MarketingPolicyPage';
import PrivacyPolicyPage from './pages/legal/PrivacyPolicyPage';
import TermsPage from './pages/legal/TermsPage';
+import Timer from './pages/Timer';
import MyPage from './pages/User/MyPage';
import Profile from './pages/User/Profile';
@@ -46,6 +47,7 @@ function App() {
} />
} />
+ } />
}>
} />
diff --git a/src/assets/svg/timer.svg b/src/assets/svg/timer.svg
new file mode 100644
index 0000000..c392daf
--- /dev/null
+++ b/src/assets/svg/timer.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/components/layout/BottomNav/index.tsx b/src/components/layout/BottomNav/index.tsx
index 2b71a30..2c8ee95 100644
--- a/src/components/layout/BottomNav/index.tsx
+++ b/src/components/layout/BottomNav/index.tsx
@@ -1,9 +1,9 @@
import clsx from 'clsx';
import { useLocation, Link } from 'react-router-dom';
-import SearchIcon from '@/assets/svg/big-search.svg';
import HouseIcon from '@/assets/svg/house.svg';
import PeopleIcon from '@/assets/svg/people.svg';
import PersonIcon from '@/assets/svg/person.svg';
+import TimerIcon from '@/assets/svg/timer.svg';
function BottomNav() {
const { pathname } = useLocation();
@@ -23,9 +23,9 @@ function BottomNav() {
총동연
-
-
- 모집
+
+
+ 모집
diff --git a/src/components/layout/Header/index.tsx b/src/components/layout/Header/index.tsx
index 326ee5c..3beb013 100644
--- a/src/components/layout/Header/index.tsx
+++ b/src/components/layout/Header/index.tsx
@@ -5,7 +5,7 @@ import useChat from '@/pages/Chat/hooks/useChat';
import { useMyInfo } from '@/pages/User/Profile/hooks/useMyInfo';
import { ROUTE_TITLES } from './routeTitles';
-const INFO_HEADER_LIST = ['/home', '/council'];
+const INFO_HEADER_LIST = ['/home', '/council', '/timer'];
function NotificationBell() {
const { totalUnreadCount } = useChat();
diff --git a/src/pages/Timer/components/RankingItem.tsx b/src/pages/Timer/components/RankingItem.tsx
new file mode 100644
index 0000000..8265057
--- /dev/null
+++ b/src/pages/Timer/components/RankingItem.tsx
@@ -0,0 +1,48 @@
+import clsx from 'clsx';
+
+interface RankingItemType {
+ rank: number | null;
+ name: string;
+ school: string;
+ total: string;
+ today: string;
+ isMe?: boolean;
+}
+
+interface RankingItemProps {
+ item: RankingItemType;
+}
+
+const RankingItem = ({ item }: RankingItemProps) => (
+
+
+ {item.rank && item.rank <= 3 ? (
+
+ {item.rank}
+
+ ) : (
+
+ {item.rank || (item.isMe ? '1290' : '')}
+
+ )}
+
+
+
+ {item.name}
+ {item.school && {item.school}}
+
+
+ 누적 공부시간 : {item.total} | 오늘 공부시간 : {item.today}
+
+
+
+);
+
+export default RankingItem;
diff --git a/src/pages/Timer/components/TimerButton.tsx b/src/pages/Timer/components/TimerButton.tsx
new file mode 100644
index 0000000..63c436e
--- /dev/null
+++ b/src/pages/Timer/components/TimerButton.tsx
@@ -0,0 +1,37 @@
+import clsx from 'clsx';
+import { formatTime } from '@/utils/ts/time';
+
+interface TimerButtonProps {
+ time: number;
+ isRunning: boolean;
+ onToggle: () => void;
+}
+
+function TimerButton({ time, isRunning, onToggle }: TimerButtonProps) {
+ return (
+
+ );
+}
+
+export default TimerButton;
diff --git a/src/pages/Timer/index.tsx b/src/pages/Timer/index.tsx
new file mode 100644
index 0000000..e08fddc
--- /dev/null
+++ b/src/pages/Timer/index.tsx
@@ -0,0 +1,111 @@
+import { useState } from 'react';
+import clsx from 'clsx';
+import { useBottomSheet } from '@/utils/hooks/useBottomSheet';
+import { useTimer } from '@/utils/hooks/useTimer';
+import RankingItem from './components/RankingItem';
+import TimerButton from './components/TimerButton';
+
+type TabType = '동아리' | '학년' | '개인';
+
+function TimerPage() {
+ const { time, isRunning, toggle } = useTimer();
+ const { position, isDragging, currentTranslate, sheetRef, handlers } = useBottomSheet();
+ const [activeTab, setActiveTab] = useState('개인');
+
+ const rankingsData: Record<
+ TabType,
+ Array<{
+ rank: number | null;
+ name: string;
+ school: string;
+ total: string;
+ today: string;
+ isMe?: boolean;
+ }>
+ > = {
+ 동아리: [
+ { rank: 4, name: 'BCSD', school: '', total: '1245시간', today: '32시간', isMe: true },
+ { rank: 1, name: 'KUT', school: '', total: '4000시간', today: '120시간' },
+ { rank: 2, name: '한소리', school: '', total: '3900시간', today: '93시간' },
+ { rank: 3, name: '비상', school: '', total: '3899시간', today: '101시간' },
+ { rank: 5, name: 'KORA', school: '', total: '1100시간', today: '45시간' },
+ { rank: 6, name: '밥버러지', school: '', total: '110시간', today: '43시간' },
+ { rank: 7, name: '동아퀴', school: '', total: '11시간', today: '4시간' },
+ ],
+ 학년: [
+ { rank: 1, name: '1학년', school: '', total: '5000시간', today: '150시간' },
+ { rank: 2, name: '2학년', school: '', total: '4500시간', today: '130시간' },
+ { rank: 3, name: '3학년', school: '', total: '4000시간', today: '100시간', isMe: true },
+ { rank: 4, name: '4학년', school: '', total: '3500시간', today: '80시간' },
+ ],
+ 개인: [
+ { rank: null, name: '이준영', school: '한국기술교육대학교', total: '50:42:49', today: '00:12:33', isMe: true },
+ { rank: 1, name: '김혜준', school: '한국기술교육대학교', total: '118:42:49', today: '12:34:44' },
+ { rank: 2, name: '공우진', school: '한국기술교육대학교', total: '112:41:49', today: '9:34:44' },
+ { rank: 3, name: '홍길동', school: '한국기술교육대학교', total: '100:30:00', today: '8:00:00' },
+ { rank: 4, name: '김철수', school: '한국기술교육대학교', total: '95:20:00', today: '7:30:00' },
+ { rank: 5, name: '이영희', school: '한국기술교육대학교', total: '90:10:00', today: '6:00:00' },
+ ],
+ };
+
+ const tabs: TabType[] = ['동아리', '학년', '개인'];
+ const rankings = rankingsData[activeTab];
+
+ const myRanking = rankings.find((item) => item.isMe);
+ const sortedRankings = rankings.slice().sort((a, b) => (a.rank ?? Infinity) - (b.rank ?? Infinity));
+
+ return (
+
+ {isRunning &&
}
+
+
+
+
+
+
+
+
+
+ {tabs.map((tab) => (
+
+ ))}
+
+
+
+ {myRanking && }
+ {sortedRankings.map((item, index) => (
+
+ ))}
+
+
+
+ );
+}
+
+export default TimerPage;
diff --git a/src/styles/theme.css b/src/styles/theme.css
index 85241b5..ceda73a 100644
--- a/src/styles/theme.css
+++ b/src/styles/theme.css
@@ -29,6 +29,7 @@
--color-indigo-900: #020b16;
/* Blue Scale */
+ --color-blue-200: #8ec2fc;
--color-blue-500: #59a7fc;
/* ========================================
diff --git a/src/utils/hooks/useBottomSheet.ts b/src/utils/hooks/useBottomSheet.ts
new file mode 100644
index 0000000..7eedc44
--- /dev/null
+++ b/src/utils/hooks/useBottomSheet.ts
@@ -0,0 +1,46 @@
+import { useRef, useState } from 'react';
+
+type SheetPosition = 'half' | 'full';
+
+export const useBottomSheet = (threshold = 30) => {
+ const [position, setPosition] = useState('half');
+ const [isDragging, setIsDragging] = useState(false);
+ const [currentTranslate, setCurrentTranslate] = useState(0);
+ const startY = useRef(0);
+ const sheetRef = useRef(null);
+
+ const handleTouchStart = (e: React.TouchEvent) => {
+ setIsDragging(true);
+ startY.current = e.touches[0].clientY;
+ };
+
+ const handleTouchMove = (e: React.TouchEvent) => {
+ if (!isDragging) return;
+ const deltaY = e.touches[0].clientY - startY.current;
+ setCurrentTranslate(deltaY);
+ };
+
+ const handleTouchEnd = () => {
+ setIsDragging(false);
+
+ if (position === 'half' && currentTranslate < -threshold) {
+ setPosition('full');
+ } else if (position === 'full' && currentTranslate > threshold) {
+ setPosition('half');
+ }
+
+ setCurrentTranslate(0);
+ };
+
+ return {
+ position,
+ isDragging,
+ currentTranslate,
+ sheetRef,
+ handlers: {
+ onTouchStart: handleTouchStart,
+ onTouchMove: handleTouchMove,
+ onTouchEnd: handleTouchEnd,
+ },
+ };
+};
diff --git a/src/utils/hooks/useTimer.ts b/src/utils/hooks/useTimer.ts
new file mode 100644
index 0000000..c419106
--- /dev/null
+++ b/src/utils/hooks/useTimer.ts
@@ -0,0 +1,44 @@
+import { useEffect, useRef, useState } from 'react';
+
+export const useTimer = () => {
+ const [time, setTime] = useState(0);
+ const [isRunning, setIsRunning] = useState(false);
+ const wasRunningRef = useRef(false);
+ const intervalRef = useRef(null);
+
+ useEffect(() => {
+ if (isRunning) {
+ intervalRef.current = setInterval(() => {
+ setTime((prevTime) => prevTime + 1);
+ }, 1000);
+ } else if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ }
+
+ return () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ }
+ };
+ }, [isRunning]);
+
+ useEffect(() => {
+ const handleVisibilityChange = () => {
+ if (document.hidden && isRunning) {
+ wasRunningRef.current = true;
+ setIsRunning(false);
+ } else if (!document.hidden && wasRunningRef.current) {
+ wasRunningRef.current = false;
+ setIsRunning(true);
+ }
+ };
+
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+ return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
+ }, [isRunning]);
+
+ const toggle = () => setIsRunning((prev) => !prev);
+ const reset = () => setTime(0);
+
+ return { time, isRunning, toggle, reset };
+};
diff --git a/src/utils/ts/time.ts b/src/utils/ts/time.ts
new file mode 100644
index 0000000..3abfc66
--- /dev/null
+++ b/src/utils/ts/time.ts
@@ -0,0 +1,7 @@
+export const formatTime = (totalSeconds: number): string => {
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ return [hours, minutes, seconds].map((unit) => String(unit).padStart(2, '0')).join(':');
+};