Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"autoprefixer": "^10.4.21",
"eslint": "^9",
"eslint-config-next": "15.3.5",
Expand Down
Binary file modified public/favicon.ico
Binary file not shown.
151 changes: 124 additions & 27 deletions src/app/reaction-test/page.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,67 @@
'use client';

import { useEffect, useMemo, useRef, useState } from 'react';
import {
getTop10Rankings,
getMyRank,
saveReactionTimeRecord,
} from '@/libs/api/reactionRanking';

type Phase = 'idle' | 'ready' | 'go' | 'tooSoon' | 'result';

// 단일 모드: 0.5s ~ 5s
const DELAY_RANGE: [number, number] = [500, 5000];

// TOP 10 랭킹 항목 타입
interface RankEntry {
user_id: number;
username: string;
user_best_time: string;
rank: string;
}

// 내 랭킹 정보 타입
interface MyRankInfo {
user_id: number;
username: string;
user_best_time: string;
best_rank: string;
}

export default function Page() {
const [phase, setPhase] = useState<Phase>('idle');
const [records, setRecords] = useState<number[]>([]);
const [current, setCurrent] = useState<number | null>(null);
const [best, setBest] = useState<number | null>(null);

const [topRankings, setTopRankings] = useState<RankEntry[]>([]);
const [myRankInfo, setMyRankInfo] = useState<MyRankInfo | null>(null);

const timerRef = useRef<NodeJS.Timeout | null>(null);
const startTsRef = useRef<number | null>(null);

// 최고 기록 로컬 저장
// 랭킹 데이터를 불러오는 함수 (두 API를 동시에 호출)
const fetchAllRankings = async () => {
try {
const [top10Res, myRankRes] = await Promise.all([
getTop10Rankings(),
getMyRank(),
]);

if (top10Res.success) {
setTopRankings(top10Res.data.topRankings);
}
if (myRankRes.success) {
setMyRankInfo(myRankRes.rank);
}
} catch (error) {
console.error('랭킹 정보를 불러오는데 실패했습니다:', error);
}
};

// 페이지가 처음 로드될 때 랭킹 정보를 불러옵니다.
useEffect(() => {
const saved = localStorage.getItem('reaction-best-ms');
if (saved) setBest(Number(saved));
fetchAllRankings();
}, []);
useEffect(() => {
if (best != null) localStorage.setItem('reaction-best-ms', String(best));
}, [best]);

// 평균
const average = useMemo(() => {
Expand All @@ -33,10 +71,7 @@ export default function Page() {
}, [records]);

const resetWaitingTimer = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (timerRef.current) clearTimeout(timerRef.current);
};

const handleStart = () => {
Expand All @@ -54,7 +89,7 @@ export default function Page() {
}, delay);
};

const handleCircleClick = () => {
const handleCircleClick = async () => {
if (phase === 'ready') {
// 너무 빨리 클릭
resetWaitingTimer();
Expand All @@ -66,9 +101,23 @@ export default function Page() {
const rt = Math.round(performance.now() - startTsRef.current);
setCurrent(rt);
setRecords((prev) => [...prev, rt]);
setBest((prev) => (prev == null ? rt : Math.min(prev, rt)));
setPhase('result');
startTsRef.current = null;

// 측정 완료 후 서버에 기록 저장 및 랭킹 갱신
try {
const saveResult = await saveReactionTimeRecord(rt);
if (saveResult.success) {
console.log('기록이 저장되었습니다.');
if (saveResult.isNewBest) {
alert('🎉 새로운 최고 기록입니다!');
}
// 기록 저장 후 최신 랭킹 정보를 다시 불러오기
fetchAllRankings();
}
} catch (error) {
console.error('기록 저장에 실패했습니다:', error);
}
}
};

Expand Down Expand Up @@ -198,25 +247,73 @@ export default function Page() {
</div>
</div>

{/* '내 순위' 카드 */}
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 flex items-center gap-2 font-semibold">
<span>🏆 최고 기록</span>
<span>📊 내 순위</span>
</h3>
<div className="flex items-center gap-3 rounded-xl border border-gray-100 bg-gray-50 p-4">
<div className="grid h-8 w-8 place-items-center rounded-full bg-indigo-100 text-indigo-600">
{myRankInfo ? (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 rounded-xl border border-gray-100 bg-gray-50 p-4">
<div>
<p className="text-sm font-medium text-gray-500">
최고 기록
</p>
<p className="text-lg font-bold">
{Math.round(parseFloat(myRankInfo.user_best_time))} ms
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium text-gray-500">
전체 순위
</p>
<p className="text-lg font-bold">
#{myRankInfo.best_rank}
</p>
</div>
</div>
<div className="text-center text-xs text-gray-400">
{myRankInfo.username} 님
</div>
</div>
<div>
<p className="text-sm font-medium">
{best != null ? `${best} ms` : '기록 없음'}
</p>
<p className="text-xs text-gray-500">
{best != null
? '세션/로컬 최저 기록'
: '게임을 시작해보세요!'}
</p>
) : (
<div className="rounded-xl border border-gray-100 bg-gray-50 p-4 text-center text-sm text-gray-500">
아직 측정 기록이 없습니다.
</div>
</div>
)}
</div>

{/*'TOP 10' 랭킹 보드*/}
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-4 flex items-center gap-2 font-semibold">
<span>🏆 TOP 10</span>
</h3>
{topRankings.length > 0 ? (
<ul className="space-y-2">
{topRankings.map((user) => (
<li
key={user.user_id}
className="flex items-center justify-between rounded-lg bg-gray-50 px-4 py-2 text-sm"
>
<div className="flex items-center gap-3">
<span className="font-bold text-gray-600 w-6 text-center">
{user.rank}
</span>
<span className="font-medium text-gray-800">
{user.username}
</span>
</div>
<span className="font-bold text-indigo-600">
{Math.round(parseFloat(user.user_best_time))} ms
</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-center text-gray-500">
랭킹 정보가 없습니다.
</p>
)}
</div>
</aside>
</div>
Expand Down
33 changes: 16 additions & 17 deletions src/components/ClientHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import LoginModal from './auth/LoginModal';
import SignupModal from './auth/SignupModal';
import ConfirmModal from '@/components/ui/ConfirmModal';
import { usePathname, useRouter } from 'next/navigation';
import { AuthUtils } from '@/libs/auth';

export default function ClientHeader() {
const router = useRouter();
Expand All @@ -27,11 +28,10 @@ export default function ClientHeader() {

// 새로고침 시에도 로그인 유지
useEffect(() => {
const at = localStorage.getItem('accessToken');
const name = localStorage.getItem('userName') || undefined;
if (at) {
// AuthUtils를 사용해서 토큰 유무 확인
if (AuthUtils.hasToken()) {
setIsAuthed(true);
setUserName(name);
setUserName(localStorage.getItem('userName') || undefined);
}

// 탭 간 동기화: 다른 탭에서 로그아웃 시 반영
Expand All @@ -55,18 +55,17 @@ export default function ClientHeader() {
password: string;
}) {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE}/api/auth/login`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
},
);
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '로그인 실패');

localStorage.setItem('accessToken', data.data.accessToken);
// AuthUtils를 사용하여 토큰 저장
AuthUtils.setToken(data.data.accessToken);

localStorage.setItem('refreshToken', data.data.refreshToken);
if (data?.data?.user?.username) {
localStorage.setItem('userName', data.data.user.username);
Expand All @@ -92,7 +91,7 @@ export default function ClientHeader() {
}) {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE}/api/auth/register`,
`${process.env.NEXT_PUBLIC_API_URL}/auth/register`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand All @@ -113,9 +112,9 @@ export default function ClientHeader() {

// 로그아웃
function handleLogout() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('userName');
// AuthUtils를 사용하여 한번에 토큰 삭제
AuthUtils.removeToken();

setIsAuthed(false);
setUserName(undefined);
setConfirmOpen(false);
Expand Down
2 changes: 1 addition & 1 deletion src/components/FaqSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const faqData = [
id: 4,
question: '알림 기능은 어떠한 원리로 작동하나요?',
answer:
'체크타임의 알림 기능은 url 서버 시간에 rtt(왕복 시간)과 사용자 반응속도 기록을 반영하여 최적의 타이밍에 알림을 제공합니다.',
'체크타임의 알림 기능은 url 서버 시간에 rtt(왕복 시간)과 인간의 시각적 반응 한계(0.1 ms)를 반영하여, 최적의 타이밍에 알림을 제공합니다.',
},
];

Expand Down
67 changes: 67 additions & 0 deletions src/libs/api/reactionRanking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { AuthUtils } from '@/libs/auth';

const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';

// API 요청을 위한 기본 fetch 함수
async function fetchApi(path: string, options: RequestInit = {}) {
const headers = new Headers({
...AuthUtils.getAuthHeaders(),
...options.headers,
});

const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers,
});

// 204 No Content 상태 코드일 경우, JSON 파싱을 시도하지 않고 null을 반환합니다.
if (response.status === 204) {
return null;
}

if (!response.ok) {
throw new Error(`API call failed: ${response.statusText}`);
}
return response.json();
}

/**
* 반응속도 기록을 서버에 저장합니다.
* @param refreshTime 측정된 반응속도 (ms)
*/
export const saveReactionTimeRecord = (refreshTime: number) => {
return fetchApi('/refresh-records', {
method: 'POST',
body: JSON.stringify({ refreshTime }),
});
};

/**
* 내 순위 정보를 가져옵니다.
*/
export const getMyRank = () => {
return fetchApi('/refresh-records/my-rank', {
method: 'GET',
});
};

/**
* 내 주변 순위 정보를 가져옵니다.
* @param range 조회할 순위 범위
*/
export const getNearbyRankings = () => {
// 템플릿 리터럴을 사용해 range 쿼리 파라미터를 추가합니다.
return fetchApi(`/refresh-records/nearby`, {
method: 'GET',
});
};

/**
* TOP 10 랭킹 정보를 가져옵니다.
*/
export const getTop10Rankings = () => {
return fetchApi('/refresh-records/stats', {
method: 'GET',
});
};