diff --git a/frontend/src/api/Game.ts b/frontend/src/api/Game.ts index 96aa5ea4..f204c637 100644 --- a/frontend/src/api/Game.ts +++ b/frontend/src/api/Game.ts @@ -5,6 +5,7 @@ import { Room } from './Room'; import { User } from './User'; import { Problem } from './Problem'; import { Color } from './Color'; +import Language from './Language'; export type Player = { user: User, @@ -82,8 +83,11 @@ export type Submission = { export type SpectateGame = { user: User, problem: Problem, + problemIndex: number, code: string, language: string, + codeList?: string[], + languageList?: Language[], }; const basePath = '/api/v1'; diff --git a/frontend/src/components/card/LeaderboardCard.tsx b/frontend/src/components/card/LeaderboardCard.tsx index bbd93666..66da5151 100644 --- a/frontend/src/components/card/LeaderboardCard.tsx +++ b/frontend/src/components/card/LeaderboardCard.tsx @@ -4,7 +4,7 @@ import { Player } from '../../api/Game'; import { LowMarginText, SmallText } from '../core/Text'; import PlayerIcon from './PlayerIcon'; import { Color } from '../../api/Color'; -import { useGetScore, useGetSubmissionTime } from '../../util/Hook'; +import { useGetSubmissionTime } from '../../util/Hook'; type ContentStyleType = { isCurrentPlayer: boolean, @@ -76,22 +76,12 @@ function LeaderboardCard(props: LeaderboardCardProps) { } = props; const [showHover, setShowHover] = useState(false); - const score = useGetScore(player); + const score = player.solved.filter((s) => s).length; const time = useGetSubmissionTime(player); - const getScoreDisplay = () => { - if (!score) { - return 0; - } - return score; - }; + const getScoreDisplay = () => `${score || 0}/${numProblems}`; - const getScorePercentage = () => { - if (!score) { - return ''; - } - return ` ${Math.round((score / numProblems) * 100)}%`; - }; + const getAllSolved = () => player.solved.every((solved: boolean) => solved); const getSubmissionTime = () => { if (!time) { @@ -115,7 +105,7 @@ function LeaderboardCard(props: LeaderboardCardProps) { nickname={player.user.nickname} active={Boolean(player.user.sessionId)} /> - !element).length === 0}>{`${place}.${getScorePercentage()}`} + {`${place}. ${getScoreDisplay()}`} {showHover ? ( @@ -123,7 +113,7 @@ function LeaderboardCard(props: LeaderboardCardProps) { {player.user.nickname} - {`Score: ${getScoreDisplay()}`} + {`Solved: ${getScoreDisplay()}`} {`Last: ${getSubmissionTime()}`} diff --git a/frontend/src/components/core/Button.tsx b/frontend/src/components/core/Button.tsx index c40d18c4..c04a5f0e 100644 --- a/frontend/src/components/core/Button.tsx +++ b/frontend/src/components/core/Button.tsx @@ -220,3 +220,28 @@ export const InvertedSmallButton = styled(SmallButton)` color: ${({ theme }) => theme.colors.text}; background: ${({ theme }) => theme.colors.white}; `; + +type ProblemNavButtonProps = { + disabled: boolean, +}; + +export const ProblemNavButton = styled(DefaultButton)` + font-size: ${({ theme }) => theme.fontSize.default}; + color: ${({ theme, disabled }) => (disabled ? theme.colors.lightgray : theme.colors.gray)}; + background-color: ${({ theme }) => theme.colors.white}; + border-radius: 5px; + width: 35px; + height: 35px; + margin: 5px; + + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.16); + + &:hover { + box-shadow: ${({ disabled }) => (disabled ? '0 1px 6px rgba(0, 0, 0, 0.16)' : '0 1px 6px rgba(0, 0, 0, 0.20)')}; + cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')}; + } + + i { + line-height: 35px; + } +`; diff --git a/frontend/src/components/game/Editor.tsx b/frontend/src/components/game/Editor.tsx index fb85f7e0..a6e6de82 100644 --- a/frontend/src/components/game/Editor.tsx +++ b/frontend/src/components/game/Editor.tsx @@ -134,6 +134,19 @@ function ResizableMonacoEditor(props: EditorProps) { }); }; + const handleCodeChange = () => { + if (onCodeChange) { + onCodeChange(codeEditor?.getValue() || ''); + } + }; + + // When spectating, clear any extraneous selections that occur when code changes + useEffect(() => { + if (codeEditor) { + codeEditor.setSelection(new monaco.Selection(0, 0, 0, 0)); + } + }, [codeEditor, liveCode]); + const handleLanguageChange = (language: Language) => { // Save the code for this language if (codeMap != null && codeEditor != null) { @@ -199,7 +212,7 @@ function ResizableMonacoEditor(props: EditorProps) { height="100%" editorDidMount={handleEditorDidMount} editorWillMount={handleEditorWillMount} - onChange={() => onCodeChange && onCodeChange(codeEditor?.getValue() || '')} + onChange={handleCodeChange} language={languageToEditorLanguage(currentLanguage)} defaultValue={defaultCode || 'Loading...'} value={liveCode} diff --git a/frontend/src/components/game/PlayerGameView.tsx b/frontend/src/components/game/PlayerGameView.tsx index e6587cc7..0fe40b7a 100644 --- a/frontend/src/components/game/PlayerGameView.tsx +++ b/frontend/src/components/game/PlayerGameView.tsx @@ -36,7 +36,7 @@ import Language from '../../api/Language'; import { routes, send, subscribe } from '../../api/Socket'; import { User } from '../../api/User'; import ProblemPanel from './ProblemPanel'; -import { useAppSelector, useBestSubmission, useGetSubmission } from '../../util/Hook'; +import { useAppSelector, useBestSubmission } from '../../util/Hook'; import { getScore, getSubmissionCount, getSubmissionTime, getSubmission, } from '../../util/Utility'; @@ -114,7 +114,9 @@ type StateRefType = { currentUser: User | null, currentCode: string, currentLanguage: string, - currentProblemIndex: number, + currentIndex: number, + codeList: string[], + languageList: Language[], } /** @@ -122,17 +124,19 @@ type StateRefType = { * the game page is used for the spectator view. spectateGame is the live data, * primarily the player code, of the player being spectated. * spectatorUnsubscribePlayer unsubscribes the spectator from the player socket - * and brings them back to the main spectator page. + * and brings them back to the main spectator page. defaultIndex is an optional + * parameter to specify which problem to open on when loading this component. */ type PlayerGameViewProps = { gameError: string, spectateGame: SpectateGame | null, spectatorUnsubscribePlayer: (() => void) | null, + defaultIndex: number | null, }; function PlayerGameView(props: PlayerGameViewProps) { const { - gameError, spectateGame, spectatorUnsubscribePlayer, + gameError, spectateGame, spectatorUnsubscribePlayer, defaultIndex, } = props; const { currentUser, game } = useAppSelector((state) => state); @@ -142,8 +146,8 @@ function PlayerGameView(props: PlayerGameViewProps) { const [problems, setProblems] = useState([]); const [languageList, setLanguageList] = useState([Language.Java]); const [codeList, setCodeList] = useState(['']); - const [currentProblemIndex, setCurrentProblemIndex] = useState(0); - let currentSubmission = useGetSubmission(currentProblemIndex, submissions); + const [currentProblemIndex, setCurrentProblemIndex] = useState(defaultIndex || 0); + const [currentSubmission, setCurrentSubmission] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(gameError); @@ -153,9 +157,21 @@ function PlayerGameView(props: PlayerGameViewProps) { // Variable to hold whether the user is subscribed to their own player socket. const [playerSocket, setPlayerSocket] = useState(null); + // References necessary for the spectator subscription callback. + const stateRef = useRef(); + stateRef.current = { + game, + currentUser, + currentCode: codeList[currentProblemIndex], + currentLanguage: languageList[currentProblemIndex], + currentIndex: currentProblemIndex, + codeList, + languageList, + }; + // Variables to hold the player stats when spectating. const [spectatedPlayer, setSpectatedPlayer] = useState(null); - const bestSubmission = useBestSubmission(spectatedPlayer); + const bestSubmission = useBestSubmission(spectatedPlayer, stateRef.current.currentIndex); useEffect(() => setProblems(game?.problems || []), [game]); @@ -170,33 +186,23 @@ function PlayerGameView(props: PlayerGameViewProps) { const getCurrentLanguage = useCallback(() => languageList[currentProblemIndex], [languageList, currentProblemIndex]); - const setOneCurrentLanguage = (newLanguage: Language) => { - setLanguageList(languageList.map((current, index) => { - if (index === currentProblemIndex) { + const setOneCurrentLanguage = useCallback((newLanguage: Language, specifiedIndex?: number) => { + setLanguageList((stateRef.current?.languageList || []).map((current, index) => { + if (index === (specifiedIndex !== undefined ? specifiedIndex : currentProblemIndex)) { return newLanguage; } return current; })); - }; + }, [currentProblemIndex]); - const setOneCurrentCode = (newCode: string) => { - setCodeList(codeList.map((current, index) => { - if (index === currentProblemIndex) { + const setOneCurrentCode = useCallback((newCode: string, specifiedIndex?: number) => { + setCodeList((stateRef.current?.codeList || []).map((current, index) => { + if (index === (specifiedIndex !== undefined ? specifiedIndex : currentProblemIndex)) { return newCode; } return current; })); - }; - - // References necessary for the spectator subscription callback. - const stateRef = useRef(); - stateRef.current = { - game, - currentUser, - currentCode: codeList[currentProblemIndex], - currentLanguage: languageList[currentProblemIndex], - currentProblemIndex, - }; + }, [currentProblemIndex]); const setDefaultCodeFromProblems = useCallback((problemsParam: Problem[], playerSubmissions: Submission[]) => { @@ -246,14 +252,22 @@ function PlayerGameView(props: PlayerGameViewProps) { currentUserParam: User | null | undefined, currentCodeParam: string | undefined, currentLanguageParam: string | undefined, - currentIndexParam: number | undefined) => { + currentIndexParam: number | undefined, + currentCodeList: string[] | undefined, + currentLanguageList: Language[] | undefined, + sendFullLists = false) => { if (gameParam && currentUserParam) { - const spectatorViewBody: string = JSON.stringify({ + const body: SpectateGame = { user: currentUserParam, problem: gameParam.problems[currentIndexParam || 0], // must satisfy problems.length > 0 - code: currentCodeParam, - language: currentLanguageParam, - }); + problemIndex: currentIndexParam || 0, + code: currentCodeParam || '', + language: currentLanguageParam || Language.Java, + codeList: sendFullLists ? currentCodeList : undefined, + languageList: sendFullLists ? currentLanguageList : undefined, + }; + const spectatorViewBody: string = JSON.stringify(body); + send( routes(gameParam.room.roomId, currentUserParam.userId).subscribe_player, {}, @@ -265,7 +279,7 @@ function PlayerGameView(props: PlayerGameViewProps) { // Send updates via socket to any spectators. useEffect(() => { sendViewUpdate(game, currentUser, codeList[currentProblemIndex], - languageList[currentProblemIndex], currentProblemIndex); + languageList[currentProblemIndex], currentProblemIndex, codeList, languageList); }, [game, currentUser, codeList, languageList, currentProblemIndex, sendViewUpdate]); // Re-subscribe in order to get the correct subscription callback. @@ -275,7 +289,8 @@ function PlayerGameView(props: PlayerGameViewProps) { if (JSON.parse(result.body).newSpectator) { sendViewUpdate(stateRef.current?.game, stateRef.current?.currentUser, stateRef.current?.currentCode, stateRef.current?.currentLanguage, - stateRef.current?.currentProblemIndex); + stateRef.current?.currentIndex, stateRef.current?.codeList, + stateRef.current?.languageList, true); } }; @@ -314,7 +329,7 @@ function PlayerGameView(props: PlayerGameViewProps) { * If default code list is empty and current user (non-spectator) is * loaded, fetch the code from the backend */ - if (!defaultCodeList.length && !currentUser.spectator) { + if (!defaultCodeList.length && currentUser && !currentUser.spectator) { let matchFound = false; // If this user refreshed and has already submitted code, load and save their latest code @@ -334,6 +349,18 @@ function PlayerGameView(props: PlayerGameViewProps) { }, [game, currentUser, defaultCodeList, setDefaultCodeFromProblems, subscribePlayer, playerSocket, getSpectatedPlayer]); + // When spectate game code changes, update the corresponding problem with that code + useEffect(() => { + if (spectateGame?.codeList && spectateGame.languageList) { + setCodeList(spectateGame.codeList); + setLanguageList(spectateGame.languageList); + } else if (spectateGame?.code && spectateGame.language + && spectateGame.problemIndex !== undefined) { + setOneCurrentCode(spectateGame.code, spectateGame.problemIndex); + setOneCurrentLanguage(spectateGame.language as Language, spectateGame.problemIndex); + } + }, [spectateGame, setOneCurrentCode, setOneCurrentLanguage]); + // Creates Event when splitter bar is dragged const onSecondaryPanelSizeChange = () => { const event = new Event('secondaryPanelSizeChange'); @@ -359,7 +386,7 @@ function PlayerGameView(props: PlayerGameViewProps) { // Set the 'test' submission type to correctly display result. // eslint-disable-next-line no-param-reassign res.submissionType = SubmissionType.Test; - currentSubmission = res; // note: this seems a bit improper (fine as long as it works ig) + setCurrentSubmission(res); }) .catch((err) => { setLoading(false); @@ -386,6 +413,7 @@ function PlayerGameView(props: PlayerGameViewProps) { // eslint-disable-next-line no-param-reassign res.submissionType = SubmissionType.Submit; setSubmissions(submissions.concat([res])); + setCurrentSubmission(res); }) .catch((err) => { setLoading(false); @@ -398,6 +426,7 @@ function PlayerGameView(props: PlayerGameViewProps) { if (problems && next < problems.length) { setCurrentProblemIndex(next); + setCurrentSubmission(getSubmission(next, submissions)); } }; @@ -406,6 +435,7 @@ function PlayerGameView(props: PlayerGameViewProps) { if (prev >= 0) { setCurrentProblemIndex(prev); + setCurrentSubmission(getSubmission(prev, submissions)); } }; @@ -439,6 +469,7 @@ function PlayerGameView(props: PlayerGameViewProps) { Spectating: {' '} {spectateGame?.user.nickname} + {currentProblemIndex === spectateGame?.problemIndex ? ' (live)' : null} @@ -457,7 +488,7 @@ function PlayerGameView(props: PlayerGameViewProps) { Submissions: {' '} - {getSubmissionCount(spectatedPlayer)} + {getSubmissionCount(spectatedPlayer, stateRef.current?.currentIndex)} @@ -477,8 +508,7 @@ function PlayerGameView(props: PlayerGameViewProps) { > p.problemId === spectateGame.problem.problemId) || 0} + index={currentProblemIndex} onNext={currentProblemIndex < problems.length - 1 ? nextProblem : null} onPrev={currentProblemIndex > 0 ? previousProblem : null} /> @@ -517,11 +547,11 @@ function PlayerGameView(props: PlayerGameViewProps) { spectateGame?.language as Language || Language.Java} + getCurrentLanguage={getCurrentLanguage} defaultCodeMap={null} currentProblem={currentProblemIndex} - defaultCode={spectateGame?.code} - liveCode={spectateGame?.code} + defaultCode={null} + liveCode={codeList[currentProblemIndex]} /> ) diff --git a/frontend/src/components/game/ProblemPanel.tsx b/frontend/src/components/game/ProblemPanel.tsx index fa52cd63..789dcfb1 100644 --- a/frontend/src/components/game/ProblemPanel.tsx +++ b/frontend/src/components/game/ProblemPanel.tsx @@ -2,7 +2,7 @@ import React from 'react'; import styled from 'styled-components'; import MarkdownEditor from 'rich-markdown-editor'; import { BottomFooterText, ProblemHeaderText, SmallText } from '../core/Text'; -import { DefaultButton, getDifficultyDisplayButton } from '../core/Button'; +import { getDifficultyDisplayButton, ProblemNavButton } from '../core/Button'; import { Copyable } from '../special/CopyIndicator'; import { CenteredContainer, Panel } from '../core/Container'; import { Problem } from '../../api/Problem'; @@ -50,32 +50,7 @@ const ProblemNavContainer = styled.div` `; const ProblemCountText = styled(SmallText)` - color: gray; -`; - -type ProblemNavButtonProps = { - disabled: boolean, -}; - -const ProblemNavButton = styled(DefaultButton)` - font-size: ${({ theme }) => theme.fontSize.default}; - color: ${({ theme, disabled }) => (disabled ? theme.colors.lightgray : theme.colors.gray)}; - background-color: ${({ theme }) => theme.colors.white}; - border-radius: 5px; - width: 35px; - height: 35px; - margin: 5px; - - box-shadow: 0 1px 6px rgba(0, 0, 0, 0.16); - - &:hover { - box-shadow: ${({ disabled }) => (disabled ? '0 1px 6px rgba(0, 0, 0, 0.16)' : '0 1px 6px rgba(0, 0, 0, 0.20)')}; - cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')}; - } - - i { - line-height: 35px; - } + color: ${({ theme }) => theme.colors.gray}; `; type ProblemPanelProps = { diff --git a/frontend/src/components/game/SpectatorGameView.tsx b/frontend/src/components/game/SpectatorGameView.tsx index 7e3f8f7c..b838a183 100644 --- a/frontend/src/components/game/SpectatorGameView.tsx +++ b/frontend/src/components/game/SpectatorGameView.tsx @@ -17,6 +17,7 @@ function SpectatorGameView() { const [spectateGame, setSpectateGame] = useState(); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const [problemIndex, setProblemIndex] = useState(0); // Unsubscribe from the player socket. const unsubscribePlayer = useCallback(() => { @@ -66,6 +67,7 @@ function SpectatorGameView() { gameError={error} spectateGame={spectateGame} spectatorUnsubscribePlayer={unsubscribePlayer} + defaultIndex={problemIndex} /> ); } @@ -78,12 +80,14 @@ function SpectatorGameView() { currentUser={currentUser} gameStartTime={game?.gameTimer.startTime || ''} viewPlayerCode={null} - spectatePlayer={(index: number) => { - if (game) { - subscribePlayer(game.room.roomId, game.players[index].user.userId!); + spectatePlayer={(playerUserId: string, probIndex: number) => { + const player = game?.players.find((p) => p.user.userId === playerUserId); + if (game && player) { + setProblemIndex(probIndex); + subscribePlayer(game.room.roomId, player.user.userId!); } }} - numProblems={game?.problems.length || 1} + problems={game?.problems || []} /> {error ? : null} {loading ? : null} diff --git a/frontend/src/components/results/PlayerResultsItem.tsx b/frontend/src/components/results/PlayerResultsItem.tsx index 462696f7..262c9bb2 100644 --- a/frontend/src/components/results/PlayerResultsItem.tsx +++ b/frontend/src/components/results/PlayerResultsItem.tsx @@ -3,9 +3,12 @@ import styled from 'styled-components'; import { Player, Submission } from '../../api/Game'; import { LowMarginText, Text } from '../core/Text'; import { Color } from '../../api/Color'; -import { useBestSubmission, useGetScore, useGetSubmissionTime } from '../../util/Hook'; +import { useBestSubmission, useGetSubmissionTime } from '../../util/Hook'; import Language, { displayNameFromLanguage } from '../../api/Language'; import { TextButton } from '../core/Button'; +import { + getScore, getSubmissionCount, getSubmissionTime, getTimeBetween, +} from '../../util/Utility'; const Content = styled.tr` border-radius: 5px; @@ -82,38 +85,24 @@ type PlayerResultsCardProps = { isCurrentPlayer: boolean, gameStartTime: string, color: Color, - numProblems: number, + problemIndex: number, onViewCode: (() => void) | null, onSpectateLive: (() => void) | null, }; function PlayerResultsItem(props: PlayerResultsCardProps) { const { - player, place, isCurrentPlayer, color, onViewCode, onSpectateLive, + player, place, isCurrentPlayer, color, gameStartTime, problemIndex, onViewCode, onSpectateLive, } = props; - const score = useGetScore(player); - const time = useGetSubmissionTime(player); - const bestSubmission : Submission | null = useBestSubmission(player); - - const getSubmissionCount = () => player.submissions.length || '0'; + const bestSubmission: Submission | null = useBestSubmission(player, problemIndex); + const finalSubmissionTime = useGetSubmissionTime(player); const getDisplayNickname = () => { const { nickname } = player.user; return `${nickname} ${isCurrentPlayer ? '(you)' : ''}`; }; - const getSubmissionTime = () => { - if (!time) { - return 'Never'; - } - - const currentTime = new Date().getTime(); - const diffMilliseconds = currentTime - new Date(time).getTime(); - const diffMinutes = Math.floor(diffMilliseconds / (60 * 1000)); - return `${diffMinutes}m ago`; - }; - const getSubmissionLanguage = () => { if (!bestSubmission) { return 'N/A'; @@ -129,6 +118,50 @@ function PlayerResultsItem(props: PlayerResultsCardProps) { ); }; + const getScoreToDisplay = () => { + // Show score of specific problem (in percent) + if (problemIndex !== -1) { + return getScore(bestSubmission); + } + + // If in overview mode, show overall number of problems solved + const { solved } = player; + return `${solved.filter((s) => s).length}/${solved.length}`; + }; + + const getTimeToDisplay = () => { + if (problemIndex !== -1) { + return getSubmissionTime(bestSubmission, gameStartTime); + } + + if (!finalSubmissionTime) { + return 'N/A'; + } + + return `${getTimeBetween(gameStartTime, finalSubmissionTime)} min`; + }; + + const getFinalColumn = () => { + if (problemIndex === -1) { + return null; + } + + if (!onSpectateLive) { + return {getSubmissionLanguage()}; + } + + return ( + + + + Launch + launch + + + + ); + }; + return ( @@ -143,27 +176,15 @@ function PlayerResultsItem(props: PlayerResultsCardProps) { - {score} + {getScoreToDisplay()} - {getSubmissionTime()} + {getTimeToDisplay()} - {getSubmissionCount()} + {getSubmissionCount(player, problemIndex)} - {!onSpectateLive ? ( - {getSubmissionLanguage()} - ) : null} - {onSpectateLive ? ( - - - - Launch - launch - - - - ) : null} + {getFinalColumn()} ); } diff --git a/frontend/src/components/results/Podium.tsx b/frontend/src/components/results/Podium.tsx index 3aab8cdc..7ce71fc8 100644 --- a/frontend/src/components/results/Podium.tsx +++ b/frontend/src/components/results/Podium.tsx @@ -3,7 +3,8 @@ import styled from 'styled-components'; import { Player } from '../../api/Game'; import { Text, MediumText } from '../core/Text'; import Language, { displayNameFromLanguage } from '../../api/Language'; -import { useBestSubmission, useGetScore, useGetSubmissionTime } from '../../util/Hook'; +import { useBestSubmission, useGetSubmissionTime } from '../../util/Hook'; +import { getTimeBetween } from '../../util/Utility'; type PodiumProps = { place: number, @@ -12,7 +13,6 @@ type PodiumProps = { inviteContent: React.ReactNode, loading: boolean, isCurrentPlayer: boolean, - numProblems: number, }; type MedalProps = { @@ -86,10 +86,10 @@ const Medal = styled.div` function Podium(props: PodiumProps) { const { - place, player, gameStartTime, loading, inviteContent, isCurrentPlayer, numProblems, + place, player, gameStartTime, loading, inviteContent, isCurrentPlayer, } = props; - const score = useGetScore(player); + const score = (player?.solved || []).filter((s) => s).length; const bestSubmission = useBestSubmission(player); const time = useGetSubmissionTime(player); @@ -133,11 +133,11 @@ function Podium(props: PodiumProps) { ); } - const percent = Math.round((score / numProblems) * 100); + const { solved } = player; return ( - Scored - {` ${percent}%`} + Solved + {` ${solved.filter((s) => s).length}/${solved.length}`} ); }; @@ -147,11 +147,7 @@ function Podium(props: PodiumProps) { return ; } - // Calculate time from start of game till best submission - const startTime = new Date(gameStartTime).getTime(); - const diffMilliseconds = new Date(time).getTime() - startTime; - const diffMinutes = Math.floor(diffMilliseconds / (60 * 1000)); - + const diffMinutes = getTimeBetween(gameStartTime, time); return ( in diff --git a/frontend/src/components/results/PreviewCodeContent.tsx b/frontend/src/components/results/PreviewCodeContent.tsx index fb614873..6da7e471 100644 --- a/frontend/src/components/results/PreviewCodeContent.tsx +++ b/frontend/src/components/results/PreviewCodeContent.tsx @@ -1,9 +1,10 @@ import React from 'react'; import styled from 'styled-components'; -import { Player, Submission } from '../../api/Game'; +import { Player } from '../../api/Game'; import Language from '../../api/Language'; import { SecondaryHeaderText } from '../core/Text'; import ResizableMonacoEditor from '../game/Editor'; +import { getBestSubmission } from '../../util/Utility'; const CodePreview = styled.div` position: relative; @@ -20,22 +21,18 @@ const CodePreview = styled.div` type PreviewCodeContentProps = { player: Player | undefined, + problemIndex: number, } function PreviewCodeContent(props: PreviewCodeContentProps) { - const { player } = props; + const { player, problemIndex } = props; + + const bestSubmission = getBestSubmission(player, problemIndex); if (player === undefined || !player || !player.submissions.length) { return null; } - let bestSubmission: Submission | undefined; - player.submissions.forEach((submission) => { - if (!bestSubmission || submission.numCorrect > bestSubmission.numCorrect) { - bestSubmission = submission; - } - }); - return (
diff --git a/frontend/src/components/results/ResultsTable.tsx b/frontend/src/components/results/ResultsTable.tsx index 71413150..7cb1c3df 100644 --- a/frontend/src/components/results/ResultsTable.tsx +++ b/frontend/src/components/results/ResultsTable.tsx @@ -1,13 +1,24 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import styled from 'styled-components'; import { Player } from '../../api/Game'; import PlayerResultsItem from './PlayerResultsItem'; import { User } from '../../api/User'; +import { NextIcon, PrevIcon } from '../core/Icon'; +import { ProblemNavButton } from '../core/Button'; +import { Problem } from '../../api/Problem'; +import { NoMarginSubtitleText } from '../core/Text'; +import { getBestSubmission } from '../../util/Utility'; -const Content = styled.table` - text-align: center; +const Content = styled.div` width: 65%; + text-align: left; + margin: 0 auto; +`; + +const TableContent = styled.table` + text-align: center; + width: 100%; min-width: 600px; margin: 0 auto; @@ -25,6 +36,18 @@ const Content = styled.table` } `; +const TopContent = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 70px; +`; + +const ProblemText = styled(NoMarginSubtitleText)` + display: inline; + margin: 0 12px; +`; + const PrimaryTableHeader = styled.th` text-align: left; `; @@ -37,39 +60,102 @@ type ResultsTableProps = { players: Player[], currentUser: User | null, gameStartTime: string, - numProblems: number, - viewPlayerCode: ((index: number) => void) | null, - spectatePlayer: ((index: number) => void) | null, + problems: Problem[], + viewPlayerCode: ((playerUserId: string, probIndex: number) => void) | null, + spectatePlayer: ((playerUserId: string, probIndex: number) => void) | null, }; function ResultsTable(props: ResultsTableProps) { const { - players, currentUser, gameStartTime, numProblems, viewPlayerCode, spectatePlayer, + players, currentUser, gameStartTime, problems, viewPlayerCode, spectatePlayer, } = props; + // A value of -1 represents the Game Overview state + const [problemIndex, setProblemIndex] = useState(-1); + const [sortedPlayers, setSortedPlayers] = useState(players); + + useEffect(() => { + // By default, already sorted by overall num problems solved + if (problemIndex === -1) { + setSortedPlayers(players); + return; + } + + setSortedPlayers([...players].sort((p1, p2) => { + const p1Best = getBestSubmission(p1, problemIndex); + const p2Best = getBestSubmission(p2, problemIndex); + + if ((p1Best?.numCorrect || 0) > (p2Best?.numCorrect || 0)) { + return -1; + } + if ((p1Best?.numCorrect || 0) < (p2Best?.numCorrect || 0)) { + return 1; + } + + return (p1Best?.startTime || '') < (p2Best?.startTime || '') ? -1 : 1; + })); + }, [players, problemIndex]); + + const nextProblem = () => { + const next = problemIndex + 1; + + if (problems && next < problems.length) { + setProblemIndex(next); + } + }; + + const previousProblem = () => { + const prev = problemIndex - 1; + + if (prev >= -1) { + setProblemIndex(prev); + } + }; + + const getFinalColumn = () => { + if (problemIndex === -1) { + return null; + } + + return !spectatePlayer ? Code : Spectate Live; + }; + return ( - - - Player - Score - Time - Submissions - {!spectatePlayer ? Code : null} - {spectatePlayer ? Spectate Live : null} - - {players?.map((player, index) => ( - viewPlayerCode(index)) : null} - onSpectateLive={spectatePlayer ? (() => spectatePlayer(index)) : null} - /> - ))} + + + + + + {problemIndex !== -1 ? `Problem ${problemIndex + 1} of ${problems.length}. ` : null} + {problemIndex !== -1 ? problems[problemIndex]?.name || '' : 'Game Overview'} + + = problems.length}> + + + + + + + Player + {problemIndex === -1 ? 'Problems Solved' : 'Tests Passed'} + Time + {problemIndex === -1 ? 'Submissions' : 'Attempts'} + {getFinalColumn()} + + {sortedPlayers?.map((player, index) => ( + viewPlayerCode(player.user.userId || '', problemIndex)) : null} + onSpectateLive={spectatePlayer ? (() => spectatePlayer(player.user.userId || '', problemIndex)) : null} + /> + ))} + ); } diff --git a/frontend/src/util/Hook.tsx b/frontend/src/util/Hook.tsx index 919eb0e7..da20e93e 100644 --- a/frontend/src/util/Hook.tsx +++ b/frontend/src/util/Hook.tsx @@ -8,51 +8,28 @@ import { FirebaseUserType } from '../redux/Account'; import { Problem } from '../api/Problem'; import { Coordinate } from '../components/special/FloatingCircle'; import app from '../api/Firebase'; - -export const useBestSubmission = (player?: Player | null) => { +import { getBestSubmission } from './Utility'; + +/** + * Finds the best submission by a player (the first one, if there's a tie). + * If an index is specified that's not -1, then this will find the best submission + * for that problem specifically. If problemIndex is -1 (overview mode), then it + * will resort back to finding the best overall submission as usual. + * + * @param player The player in question + * @param problemIndex Optional index to specify a problem + */ +export const useBestSubmission = (player?: Player | null, problemIndex?: number) => { const [bestSubmission, setBestSubmission] = useState(null); useEffect(() => { - if (player) { - let newBestSubmission: Submission | null = null; - - // Find best submission - player.submissions.forEach((submission) => { - if (!newBestSubmission || submission.numCorrect > newBestSubmission.numCorrect) { - newBestSubmission = submission; - } - }); - - setBestSubmission(newBestSubmission); - } - }, [player, setBestSubmission]); + setBestSubmission(getBestSubmission(player, problemIndex)); + }, [player, setBestSubmission, problemIndex]); return bestSubmission; }; -export const useGetScore = (player?: Player) => { - const counted = new Set(); - const [score, setScore] = useState(0); - - useEffect(() => { - if (player) { - for (let i = 0; i < player.submissions.length; i += 1) { - if (player.submissions[i].numCorrect === player.submissions[i].numTestCases - && !counted.has(player.submissions[i].problemIndex)) { - counted.add(player.submissions[i].problemIndex); - } - } - - setScore(counted.size); - } - }, [player, setScore, counted]); - - if (player == null || player.submissions.length === 0) { - return null; - } - return score; -}; - +// Calculates the time taken for the latest 100% correct solution for a problem export const useGetSubmissionTime = (player?: Player) => { const counted = new Set(); const [time, setTime] = useState(); @@ -76,22 +53,6 @@ export const useGetSubmissionTime = (player?: Player) => { return time; }; -// Returns the most recent submission made for problem of index curr. -export const useGetSubmission = (curr: number, playerSubmissions: Submission[]) => { - const [submission, setSubmission] = useState(null); - - useEffect(() => { - for (let i = playerSubmissions.length - 1; i >= 0; i -= 1) { - if (playerSubmissions[i].problemIndex === curr) { - setSubmission(playerSubmissions[i]); - i = -1; - } - } - }, [curr, playerSubmissions]); - - return submission; -}; - export const useProblemEditable = (user: FirebaseUserType | null, problem: Problem | null) => { const [editable, setEditable] = useState(false); diff --git a/frontend/src/util/Utility.ts b/frontend/src/util/Utility.ts index 96b1277e..7664e0db 100644 --- a/frontend/src/util/Utility.ts +++ b/frontend/src/util/Utility.ts @@ -118,6 +118,7 @@ export const problemMatchesFilterText = (problem: Problem | SelectableProblem, return true; }; +// Displays the percentage correct of a specific submission export const getScore = (bestSubmission: Submission | null) => { if (!bestSubmission) { return '0'; @@ -127,28 +128,59 @@ export const getScore = (bestSubmission: Submission | null) => { return `${percent}%`; }; +// Find and return the best submission. A non-hook variant of useBestSubmission +export const getBestSubmission = (player?: Player | null, problemIndex?: number) => { + let newBestSubmission = null as Submission | null; + + if (player) { + // If problemIndex is specified, find best submission only for that problem + const submissions = (problemIndex !== undefined && problemIndex !== -1) + ? player.submissions.filter((s) => s.problemIndex === problemIndex) : player.submissions; + + submissions.forEach((submission) => { + if (!newBestSubmission || submission.numCorrect > newBestSubmission.numCorrect) { + newBestSubmission = submission; + } + }); + } + + return newBestSubmission; +}; + +export const getTimeBetween = (start: string, end: string) => { + // Calculate time from start of game till best submission + const startTime = new Date(start).getTime(); + const diffMilliseconds = new Date(end).getTime() - startTime; + return Math.floor(diffMilliseconds / (60 * 1000)); +}; + +// Displays the time taken for a specific submission export const getSubmissionTime = (bestSubmission: Submission | null, gameStartTime: string | null) => { if (!bestSubmission || !gameStartTime) { return 'N/A'; } - // Calculate time from start of game till best submission - const startTime = new Date(gameStartTime).getTime(); - const diffMilliseconds = new Date(bestSubmission.startTime).getTime() - startTime; - const diffMinutes = Math.floor(diffMilliseconds / (60 * 1000)); - - return ` ${diffMinutes} min`; + return ` ${getTimeBetween(gameStartTime, bestSubmission.startTime)} min`; }; -export const getSubmissionCount = (player: Player | null) => player?.submissions.length || '0'; +// Gets the number of submissions for a specific player and problem +export const getSubmissionCount = (player: Player | null, problemIndex?: number): number => { + const submissions = (problemIndex !== undefined && problemIndex !== -1) + ? player?.submissions.filter((s) => s.problemIndex === problemIndex) : player?.submissions; + + return submissions?.length || 0; +}; +// Returns the most recent submission made for problem of index curr (or null if none) export const getSubmission = (curr: number, playerSubmissions: Submission[]) => { for (let i = playerSubmissions.length - 1; i >= 0; i -= 1) { if (playerSubmissions[i].problemIndex === curr) { return playerSubmissions[i]; } } + + return null; }; /** diff --git a/frontend/src/views/Game.tsx b/frontend/src/views/Game.tsx index 39b371c6..fded63b8 100644 --- a/frontend/src/views/Game.tsx +++ b/frontend/src/views/Game.tsx @@ -220,6 +220,7 @@ function GamePage() { gameError={error} spectateGame={null} spectatorUnsubscribePlayer={null} + defaultIndex={0} /> ) } diff --git a/frontend/src/views/Results.tsx b/frontend/src/views/Results.tsx index 6318306b..4661e15d 100644 --- a/frontend/src/views/Results.tsx +++ b/frontend/src/views/Results.tsx @@ -98,7 +98,12 @@ function GameResultsPage() { const [hoverVisible, setHoverVisible] = useState(false); const [showFeedbackModal, setShowFeedbackModal] = useState(false); const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); + + // If not -1, codeModal represents the index of the player whose code should show in the modal const [codeModal, setCodeModal] = useState(-1); + const [problemIndex, setProblemIndex] = useState(0); + + // If not -1, placeModal is set to the index of the player whose place should show in the modal const [placeModal, setPlaceModal] = useState(-1); const [displayPlaceModal, setDisplayPlaceModal] = useState(true); @@ -211,6 +216,11 @@ function GameResultsPage() { return 'th'; }; + const onViewPlayerCode = (playerUserId: string, probIndex: number) => { + setProblemIndex(probIndex); + setCodeModal(players.findIndex((p) => p.user.userId === playerUserId)); + }; + // Reset hover status on host changes useEffect(() => { setHoverVisible(false); @@ -248,6 +258,7 @@ function GameResultsPage() { setCodeModal(-1)} fullScreen> @@ -281,7 +292,6 @@ function GameResultsPage() { inviteContent={inviteContent()} loading={loading} isCurrentPlayer={players[1]?.user.userId === currentUser?.userId} - numProblems={game?.problems.length || 1} /> @@ -342,8 +350,8 @@ function GameResultsPage() { players={players} currentUser={currentUser} gameStartTime={startTime} - numProblems={game?.problems.length || 1} - viewPlayerCode={(index: number) => setCodeModal(index)} + problems={game?.problems || []} + viewPlayerCode={onViewPlayerCode} spectatePlayer={null} /> ) : null}