diff --git a/frontend/src/components/Common/Loading.tsx b/frontend/src/components/Common/Loading.tsx
new file mode 100644
index 0000000..cb74d11
--- /dev/null
+++ b/frontend/src/components/Common/Loading.tsx
@@ -0,0 +1,35 @@
+interface Props {
+ size: string;
+ color: string;
+}
+
+export default function Loading({ size, color }: Props) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/Submission/Connecting.tsx b/frontend/src/components/Submission/Connecting.tsx
new file mode 100644
index 0000000..3ba2fdc
--- /dev/null
+++ b/frontend/src/components/Submission/Connecting.tsx
@@ -0,0 +1,22 @@
+import { css } from '@style/css';
+
+import Loading from '@/components/Common/Loading';
+
+interface Props {
+ isConnected: boolean;
+}
+
+export default function Connecting(props: Props) {
+ if (!props.isConnected) return null;
+
+ return (
+
+ 연결 중...
+
+
+ );
+}
+const rowStyle = css({
+ display: 'flex',
+ gap: '0.5rem',
+});
diff --git a/frontend/src/components/Submission/SubmissionResult.tsx b/frontend/src/components/Submission/SubmissionResult.tsx
index a6d9fe6..2d6341e 100644
--- a/frontend/src/components/Submission/SubmissionResult.tsx
+++ b/frontend/src/components/Submission/SubmissionResult.tsx
@@ -2,6 +2,7 @@ import { css } from '@style/css';
import { useEffect, useState } from 'react';
+import Connecting from '@/components/Submission/Connecting';
import { range } from '@/utils/array';
import type { Socket } from '@/utils/socket';
@@ -10,6 +11,7 @@ import { type Message, type ScoreResult, SUBMIT_STATE, type SubmitState } from '
interface Props {
socket: Socket;
+ isConnected: boolean;
}
type SubmitResult = {
@@ -18,7 +20,7 @@ type SubmitResult = {
score?: ScoreResult;
};
-export function SubmissionResult({ socket }: Props) {
+export function SubmissionResult({ socket, isConnected }: Props) {
const [scoreResults, setScoreResults] = useState([]);
const [submissionMessage, setSubmissionMessage] = useState('');
@@ -71,6 +73,7 @@ export function SubmissionResult({ socket }: Props) {
return (
<>
+
{submissionMessage}
{scoreResults.map(({ score, submitState, testcaseId }) => (
diff --git a/frontend/src/components/Timer/index.tsx b/frontend/src/components/Timer/index.tsx
new file mode 100644
index 0000000..ff15dfe
--- /dev/null
+++ b/frontend/src/components/Timer/index.tsx
@@ -0,0 +1,60 @@
+import { css } from '@style/css';
+
+import Loading from '@/components/Common/Loading';
+import useTimer from '@/hooks/timer/useTimer';
+import { formatMilliSecond } from '@/utils/date';
+import type { Socket } from '@/utils/socket';
+
+interface Props {
+ socket: Socket;
+ endsAt: Date;
+ isConnected?: boolean;
+}
+
+export default function Timer(props: Props) {
+ let { socket, endsAt, isConnected } = props;
+ // api 연결이 X endsAt 대신 임시로 만들어놓은 것.
+ endsAt = new Date('2023-11-29T13:10:10.000Z');
+ const { remainMiliSeconds } = useTimer({ socket, endsAt });
+
+ if (isConnected && remainMiliSeconds !== -1) {
+ // 연결도 되어있고, 서버 시간도 도착해서 count down을 시작할 수 있을 때
+ return (
+
+
+ {formatMilliSecond(remainMiliSeconds, 'hh:mm:ss')}
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+const wrapperStyle = css({
+ display: 'flex',
+ alignItems: 'center',
+});
+
+const disconnectedStyle = css({
+ color: 'darkred',
+});
+
+const loadingBoxStyle = css({
+ display: 'flex',
+ gap: '1rem',
+});
+
+const timeTextStyle = css({
+ color: 'lightgray',
+ fontWeight: 'bold',
+});
diff --git a/frontend/src/hooks/competition/useCompetition.ts b/frontend/src/hooks/competition/useCompetition.ts
index dd19dd9..a4204b8 100644
--- a/frontend/src/hooks/competition/useCompetition.ts
+++ b/frontend/src/hooks/competition/useCompetition.ts
@@ -25,6 +25,7 @@ const notFoundCompetition: CompetitionInfo = {
export const useCompetition = (competitionId: number) => {
const [competition, setCompetition] = useState(notFoundCompetition);
+ const [isConnected, setIsConnected] = useState(false);
const socket = useRef(
createSocketInstance('/competitions', {
@@ -38,10 +39,12 @@ export const useCompetition = (competitionId: number) => {
const handleConnect = () => {
console.log('connected!');
+ setIsConnected(true);
};
const handleDisconnect = () => {
console.log('disconnected!');
+ setIsConnected(false);
};
useEffect(() => {
@@ -77,5 +80,6 @@ export const useCompetition = (competitionId: number) => {
socket,
competition,
submitSolution,
+ isConnected,
};
};
diff --git a/frontend/src/hooks/timer/useTimer.ts b/frontend/src/hooks/timer/useTimer.ts
new file mode 100644
index 0000000..fba4d63
--- /dev/null
+++ b/frontend/src/hooks/timer/useTimer.ts
@@ -0,0 +1,56 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+import { Socket } from '@/utils/socket';
+
+interface UseTimer {
+ socket: Socket;
+ endsAt: Date;
+ onTimeoutHandler?: () => void;
+}
+
+export default function useTimer({ socket, endsAt, onTimeoutHandler }: UseTimer) {
+ const timerIntervalId = useRef(null);
+ const endTime = useMemo(() => endsAt.getTime(), [endsAt]);
+ const [remainMiliSeconds, setRemainMiliSeconds] = useState(-1);
+ useEffect(() => {
+ console.log('타이머 실행');
+ // 웹 소켓 대신 사용.
+ mockWebSocket();
+ if (!socket.hasListeners('ping')) {
+ socket.on('ping', handlePingMessage);
+ }
+ }, [socket]);
+
+ const handlePingMessage = useCallback((time: Date | string) => {
+ if (timerIntervalId.current) clearInterval(timerIntervalId.current);
+
+ time = typeof time === 'string' ? new Date(time) : time;
+ const remainMiliSec = endTime - time.getTime();
+ setRemainMiliSeconds(remainMiliSec);
+ timerIntervalId.current = setInterval(() => {
+ console.log('1초마다 실행');
+ setRemainMiliSeconds((prev) => prev - 1000);
+ }, 1000);
+ }, []);
+
+ // 웹 소켓 대신 사용.
+ // 웹 소켓 연결 후 삭제 예정
+ const mockWebSocket = useCallback(() => {
+ const delayFactor = 2000;
+ setInterval(() => {
+ console.log('ping 5초( + 네트워크 지연) 마다 실행');
+ const serverTime = new Date();
+ handlePingMessage(serverTime);
+ }, 5000 + Math.random() * delayFactor);
+ }, []);
+
+ useEffect(() => {
+ // TODO time 0이면 대시보드로 이동하는 로직
+ // 해당 PR에서 해결할 문제는 아니라 PASS
+ if (Math.floor(remainMiliSeconds / 1000) <= 0) {
+ if (typeof onTimeoutHandler === 'function') onTimeoutHandler();
+ // 나가는 로직
+ }
+ }, [remainMiliSeconds]);
+ return { remainMiliSeconds };
+}
diff --git a/frontend/src/pages/ContestPage.tsx b/frontend/src/pages/ContestPage.tsx
index 5352823..fe55a8f 100644
--- a/frontend/src/pages/ContestPage.tsx
+++ b/frontend/src/pages/ContestPage.tsx
@@ -11,6 +11,7 @@ import ProblemViewer from '@/components/Problem/ProblemViewer';
import { SimulationInputModal } from '@/components/Simulation/SimulationInputModal';
import { SimulationResultList } from '@/components/Simulation/SimulationResultList';
import { SubmissionResult } from '@/components/Submission';
+import Timer from '@/components/Timer';
import { SITE } from '@/constants';
import type { SubmissionForm } from '@/hooks/competition';
import { useCompetition } from '@/hooks/competition';
@@ -30,7 +31,7 @@ export default function ContestPage() {
const simulation = useSimulation();
- const { socket, competition, submitSolution } = useCompetition(competitionId);
+ const { socket, competition, submitSolution, isConnected } = useCompetition(competitionId);
const { problemList } = useCompetitionProblemList(competitionId);
const currentProblem = useMemo(() => {
@@ -81,17 +82,22 @@ export default function ContestPage() {
submitSolution(form);
}
+
+ const { endsAt } = competition;
+
function handleOpenModal() {
modal.open();
}
-
+
const problems = problemList.map((problem) => problem.id);
+
return (
-
+
@@ -154,3 +160,8 @@ const problemTitleStyle = css({
const execButtonStyle = css({
color: 'black',
});
+
+const rowStyle = css({
+ display: 'flex',
+ justifyContent: 'space-between',
+});
diff --git a/frontend/src/utils/date/index.ts b/frontend/src/utils/date/index.ts
index 367eb7a..96ed403 100644
--- a/frontend/src/utils/date/index.ts
+++ b/frontend/src/utils/date/index.ts
@@ -1,5 +1,8 @@
const ONE_SEC_BY_MS = 1_000;
const ONE_MIN_BY_MS = 60 * ONE_SEC_BY_MS;
+const ONE_MIN_BY_SEC = 60;
+const ONE_HOUR_BY_MIN = 60;
+const ONE_HOUR_BY_SEC = ONE_HOUR_BY_MIN * ONE_MIN_BY_SEC;
export function toLocalDate(date: Date) {
const localTimeOffset = date.getTimezoneOffset() * ONE_MIN_BY_MS;
@@ -15,3 +18,16 @@ export const formatDate = (date: Date, form: string) => {
return '';
};
+
+export const formatMilliSecond = (ms: number, form: string) => {
+ const sec = Math.floor(ms / ONE_SEC_BY_MS);
+
+ if (form === 'hh:mm:ss') {
+ // 시간(초)을 'hh:mm:ss' 형식으로 변환
+ const hours = Math.floor(sec / ONE_HOUR_BY_SEC);
+ const minutes = Math.floor((sec % ONE_HOUR_BY_SEC) / ONE_MIN_BY_SEC);
+ const seconds = sec % ONE_MIN_BY_SEC;
+ return [hours, minutes, seconds].map((time) => String(time).padStart(2, '0')).join(':');
+ }
+ return '';
+};