Skip to content

Commit

Permalink
Merge pull request #143 from boostcampwm2023/132-대회-페이지-내의-웹소켓-연결-상태를…
Browse files Browse the repository at this point in the history
…-확인할-수-있는-기능

[#132] 대회 페이지 내의 웹소켓 연결 상태를 확인할 수 있는 기능
  • Loading branch information
mahwin authored Nov 28, 2023
2 parents 239016c + 681eea6 commit 2ffee35
Show file tree
Hide file tree
Showing 8 changed files with 211 additions and 4 deletions.
35 changes: 35 additions & 0 deletions frontend/src/components/Common/Loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
interface Props {
size: string;
color: string;
}

export default function Loading({ size, color }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
>
<circle
cx="50"
cy="50"
fill="none"
stroke={color}
stroke-width="10"
r="35"
stroke-dasharray="164.93361431346415 56.97787143782138"
>
<animateTransform
attributeName="transform"
type="rotate"
repeatCount="indefinite"
dur="1s"
values="0 50 50;360 50 50"
keyTimes="0;1"
></animateTransform>
</circle>
</svg>
);
}
22 changes: 22 additions & 0 deletions frontend/src/components/Submission/Connecting.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={rowStyle}>
<span>연결 중...</span>
<Loading color="darkorange" size="24px" />
</div>
);
}
const rowStyle = css({
display: 'flex',
gap: '0.5rem',
});
5 changes: 4 additions & 1 deletion frontend/src/components/Submission/SubmissionResult.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -10,6 +11,7 @@ import { type Message, type ScoreResult, SUBMIT_STATE, type SubmitState } from '

interface Props {
socket: Socket;
isConnected: boolean;
}

type SubmitResult = {
Expand All @@ -18,7 +20,7 @@ type SubmitResult = {
score?: ScoreResult;
};

export function SubmissionResult({ socket }: Props) {
export function SubmissionResult({ socket, isConnected }: Props) {
const [scoreResults, setScoreResults] = useState<SubmitResult[]>([]);
const [submissionMessage, setSubmissionMessage] = useState<string>('');

Expand Down Expand Up @@ -71,6 +73,7 @@ export function SubmissionResult({ socket }: Props) {
return (
<>
<section className={resultWrapperStyle}>
<Connecting isConnected={isConnected} />
<p>{submissionMessage}</p>
{scoreResults.map(({ score, submitState, testcaseId }) => (
<Score key={testcaseId} score={score} submitState={submitState} />
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/components/Timer/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className={wrapperStyle}>
<div>
<span className={timeTextStyle}>{formatMilliSecond(remainMiliSeconds, 'hh:mm:ss')}</span>
</div>
</section>
);
}

return (
<section className={wrapperStyle}>
<div>
<section className={loadingBoxStyle}>
<span className={disconnectedStyle}>연결 중...</span>
<Loading color="darkred" size="24px" />
</section>
</div>
</section>
);
}

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',
});
4 changes: 4 additions & 0 deletions frontend/src/hooks/competition/useCompetition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const notFoundCompetition: CompetitionInfo = {

export const useCompetition = (competitionId: number) => {
const [competition, setCompetition] = useState<CompetitionInfo>(notFoundCompetition);
const [isConnected, setIsConnected] = useState<boolean>(false);

const socket = useRef(
createSocketInstance('/competitions', {
Expand All @@ -38,10 +39,12 @@ export const useCompetition = (competitionId: number) => {

const handleConnect = () => {
console.log('connected!');
setIsConnected(true);
};

const handleDisconnect = () => {
console.log('disconnected!');
setIsConnected(false);
};

useEffect(() => {
Expand Down Expand Up @@ -77,5 +80,6 @@ export const useCompetition = (competitionId: number) => {
socket,
competition,
submitSolution,
isConnected,
};
};
56 changes: 56 additions & 0 deletions frontend/src/hooks/timer/useTimer.ts
Original file line number Diff line number Diff line change
@@ -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<NodeJS.Timeout | null>(null);
const endTime = useMemo(() => endsAt.getTime(), [endsAt]);
const [remainMiliSeconds, setRemainMiliSeconds] = useState<number>(-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 };
}
17 changes: 14 additions & 3 deletions frontend/src/pages/ContestPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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 (
<main className={style}>
<CompetitionHeader crumbs={crumbs} id={competitionId} />
<section>
<span className={problemTitleStyle}>{problem.title}</span>
<Timer socket={socket.current} isConnected={isConnected} endsAt={new Date(endsAt)} />
</section>
<section className={rowListStyle}>
<ContestProblemSelector
Expand All @@ -114,7 +120,7 @@ export default function ContestPage() {
</div>
</section>
<section>
<SubmissionResult socket={socket.current}></SubmissionResult>
<SubmissionResult isConnected={isConnected} socket={socket.current}></SubmissionResult>
<button className={execButtonStyle} onClick={handleSubmitSolution}>
제출하기
</button>
Expand Down Expand Up @@ -154,3 +160,8 @@ const problemTitleStyle = css({
const execButtonStyle = css({
color: 'black',
});

const rowStyle = css({
display: 'flex',
justifyContent: 'space-between',
});
16 changes: 16 additions & 0 deletions frontend/src/utils/date/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 '';
};

0 comments on commit 2ffee35

Please sign in to comment.