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
2 changes: 1 addition & 1 deletion backend/api/play/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,4 @@ def get_player_games_json(player: Player, page: int, limit: int, include_moves:
"""Returns a list of games played by the player with the given username."""

games = Game.getGamesByPlayer(player).order_by("-date")[limit * (page - 1) : min(limit * page, 2**63)]
return [game_to_dict(game, include_moves) for game in games]
return [game_to_dict(game, include_moves, relativeUserStatusToPlayer=player) for game in games]
13 changes: 9 additions & 4 deletions backend/tests/unit/api/play/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,19 @@ def test_get_player_games_json() -> None:
games = get_player_games_json(player1, page=1, limit=10)
assert len(games) == 1
assert games[0]["game_id"] == 1
assert games[0]["players"]["white"] == {"user_type": "registered", "username": "user1"}
assert games[0]["players"]["black"] == {"user_type": "registered", "username": "user2"}
assert games[0]["players"]["white"] == {"user_type": "registered", "username": "user1", "is_current_user": True}
assert games[0]["players"]["black"] == {
"user_type": "registered",
"username": "user2",
"is_current_user": False,
"status": FriendStatus.notFriends.value,
}

games2 = get_player_games_json(player2, page=1, limit=10)
assert len(games2) == 2
assert games2[0]["game_id"] == 2

assert games2[0]["players"]["white"] == {"user_type": "registered", "username": "user2"}
assert games2[0]["players"]["black"] == {"user_type": "anonymous", "user_id": 1}
assert games2[0]["players"]["white"] == {"user_type": "registered", "username": "user2", "is_current_user": True}
assert games2[0]["players"]["black"] == {"user_type": "anonymous", "user_id": 1, "is_current_user": False}

assert games2[1]["game_id"] == 1
2 changes: 1 addition & 1 deletion frontend/src/components/Profile/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default function Profile() {
friendStatus={(profileData && profileData.friend_status) || null}
username={username}
/>
<RecentGames games={profileData && profileData.games} username={username} />
<RecentGames games={profileData && profileData.games} />
</Paper>
</>
);
Expand Down
49 changes: 21 additions & 28 deletions frontend/src/components/Profile/RecentGames/GameRow/GameRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,34 @@
import { GAME_TERMINATION_EXPLANATION } from "components/constants";
import React from "react";
import { useNavigate } from "react-router-dom";
import { GAME_WINNER, GameApiResponse, GameWinner } from "types/api/game";
import { GAME_WINNER, GameWinner, SimpleGameApiResponse } from "types/api/game";
import { PlayersApi } from "types/api/player";
import { GAME_OUTCOME, GameOutcome, PlayerColor, PlayerInfo } from "types/game";
import { formatDateString, secToTime } from "utils/utils";
import { CSS } from "./css";
import OpponentUserButton from "./OpponentUserButton/OpponentUserButton";

const GAME_OUTCOME_TEXT = {
[GAME_OUTCOME.WON]: "Won",
[GAME_OUTCOME.LOST]: "Lost",
[GAME_OUTCOME.DRAW]: "Draw",
} as const;

type Props = { game: GameApiResponse; username: string; width: number };
type Props = { game: SimpleGameApiResponse; width: number };

export default function GameRow(props: Props) {
const { game, username, width } = props;
const { game, width } = props;

const [userHovered, setUserHovered] = React.useState(false);
const [isOpponentButtonHovered, setIsOpponentButtonHovered] = React.useState(false);
const navigate = useNavigate();

const handleUserOnClick = (event: React.MouseEvent) => {
event.stopPropagation();
navigate(`/profile/${opponent.username}`);
};

const handleGameOnClick = () => {
navigate(`/replay_game/${game.game_id}`);
};

const displayResultLong = width > 800;

const { player, opponent } = getPlayerInfos(game.players, username);
const { player, opponent } = getPlayerInfos(game.players);

const gameOutcome = getGameOutcome(game.winner_color, player.color);
const gameOutcomeText = GAME_OUTCOME_TEXT[gameOutcome];
Expand All @@ -44,17 +41,16 @@ export default function GameRow(props: Props) {
const dateString = formatDateString(props.game.date);

return (
<tr css={[CSS.GAME_ROW, !userHovered && CSS.HOVER_ENABLED]} onClick={handleGameOnClick}>
<td
css={CSS.USERNAME}
onClick={handleUserOnClick}
onMouseEnter={() => setUserHovered(true)}
onMouseLeave={() => setUserHovered(false)}
>
{opponent.username}
</td>
<tr
css={[CSS.GAME_ROW, !isOpponentButtonHovered && CSS.HOVER_ENABLED]}
onClick={handleGameOnClick}
data-testid={`game-row`}
>
<OpponentUserButton opponent={opponent} setIsOpponentButtonHovered={setIsOpponentButtonHovered} />
<td>
<span css={gameOutcomeTextCss}>{gameOutcomeText}</span>
<span css={gameOutcomeTextCss} data-testid={`game-outcome-text`}>
{gameOutcomeText}
</span>
{displayResultLong && ` ${resultText}`}
</td>
<td>{timeControl}</td>
Expand All @@ -77,11 +73,8 @@ const getGameOutcome = (winnerColor: GameWinner, playerColor: PlayerColor): Game
/**
* Gets the player infos matching their username to their color, returns both player and opponent.
*/
const getPlayerInfos = (
players: GameApiResponse["players"],
username: string,
): { player: PlayerInfo; opponent: PlayerInfo } => {
const isPlayerWhite = players.white.username === username;
const getPlayerInfos = (players: PlayersApi): { player: PlayerInfo; opponent: PlayerInfo } => {
const isPlayerWhite = players.white.is_current_user;
const playerColor = isPlayerWhite ? GAME_WINNER.WHITE : GAME_WINNER.BLACK;
const player = getPlayerInfo(players, playerColor);

Expand All @@ -91,8 +84,8 @@ const getPlayerInfos = (
return { player, opponent };
};

const getPlayerInfo = (players: GameApiResponse["players"], playerColor: PlayerColor): PlayerInfo => {
const { username } = players[playerColor];
const getPlayerInfo = (players: PlayersApi, playerColor: PlayerColor): PlayerInfo => {
const playerApi = players[playerColor];

return { username, color: playerColor };
return { ...playerApi, color: playerColor };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/** @jsxImportSource @emotion/react */
import ProfilePicture from "components/shared/ProfilePicture";
import React from "react";
import { IoIosGlobe } from "react-icons/io";
import { useNavigate } from "react-router-dom";
import { PlayerApi, RegisteredPlayerApi } from "types/api/player";
import { CSS } from "./css";

type Props = {
opponent: PlayerApi;
setIsOpponentButtonHovered: React.Dispatch<React.SetStateAction<boolean>>;
};

export default function OpponentUserButton(props: Props) {
const { opponent, setIsOpponentButtonHovered } = props;

const navigate = useNavigate();

const handleOpponentOnClick = (event: React.MouseEvent) => {
if (!isOpponentRegistered) return;

event.stopPropagation();
navigate(`/profile/${opponent.username}`);
};

const isOpponentRegistered = opponent.user_type !== "anonymous";
const OpponentDisplay = isOpponentRegistered
? getRegisteredOpponentDisplay(opponent)
: getAnonymousOpponentDisplay();

return (
<td
css={isOpponentRegistered && CSS.OPPONENT_PROFILE_BUTTON}
onClick={handleOpponentOnClick}
onMouseEnter={() => isOpponentRegistered && setIsOpponentButtonHovered(true)}
onMouseLeave={() => isOpponentRegistered && setIsOpponentButtonHovered(false)}
data-testid={`opponent-user-button`}
>
{OpponentDisplay}
</td>
);
}

const getRegisteredOpponentDisplay = (opponent: RegisteredPlayerApi) => {
return (
<div css={CSS.OPPONENT_USER_WRAPPER}>
<ProfilePicture username={opponent.username} customCss={CSS.PROFILE_PICTURE_ICON} />
{opponent.username}
</div>
);
};

const getAnonymousOpponentDisplay = () => {
return (
<div css={CSS.OPPONENT_USER_WRAPPER}>
<IoIosGlobe css={CSS.ANONYMOUS_USER_ICON} />
Anonymous
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { css } from "@emotion/react";

const OPPONENT_PROFILE_BUTTON = css`
:hover {
text-decoration: underline;
background-color: #4d4d4d;
}
`;
const PROFILE_PICTURE_ICON = css`
width: 1.75em;
height: 1.75em;
`;
const ANONYMOUS_USER_ICON = css`
width: 2.2em;
height: 2.2em;
// The icon is not 100% size so to scale it the same as the profile picture we have to adjust the margin
margin: -0.25em;
`;
const OPPONENT_USER_WRAPPER = css`
display: flex;
align-items: center;
gap: 0.5em;
`;

export const CSS = {
OPPONENT_PROFILE_BUTTON,
PROFILE_PICTURE_ICON,
ANONYMOUS_USER_ICON,
OPPONENT_USER_WRAPPER,
} as const;
7 changes: 0 additions & 7 deletions frontend/src/components/Profile/RecentGames/GameRow/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,6 @@ const HOVER_ENABLED = css`
}
}
`;
const USERNAME = css`
:hover {
text-decoration: underline;
background-color: #4d4d4d;
}
`;
const WINNER_COLORS = {
[GAME_OUTCOME.WON]: css`
color: #00ff00;
Expand All @@ -40,6 +34,5 @@ const WINNER_COLORS = {
export const CSS = {
GAME_ROW,
HOVER_ENABLED,
USERNAME,
WINNER_COLORS,
} as const;
17 changes: 9 additions & 8 deletions frontend/src/components/Profile/RecentGames/RecentGames.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { css } from "@emotion/react";
import Paper from "components/shared/Paper";
import useWindowSize from "hooks/useWindowSize";
import { GameApiResponse } from "types/api/game";
import { SimpleGameApiResponse } from "types/api/game";
import GameRow from "./GameRow/GameRow";
import HeaderRow from "./HeaderRow/HeaderRow";

Expand Down Expand Up @@ -42,22 +42,23 @@ const GamesTableCss = css`
}
`;

type Props = { games: GameApiResponse[] | null; username: string };
type Props = { games: SimpleGameApiResponse[] | null };
export default function RecentGames(props: Props) {
const size = useWindowSize();
if (size.width === undefined) return null;

const GameRows =
props.games &&
props.games.map((game, index) => {
return <GameRow key={index} game={game} width={size.width!} />;
});

return (
<Paper elevation={1} white={true}>
<h2 css={TitleCss}>Recent Games</h2>
<table css={GamesTableCss}>
<HeaderRow />
<tbody>
{props.games &&
props.games.map((game, index) => (
<GameRow key={index} game={game} username={props.username} width={size.width!} />
))}
</tbody>
<tbody>{GameRows}</tbody>
</table>
</Paper>
);
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/ReplayGame/hooks/useReplayGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { useChessBoardState } from "components/shared/Chess/useChessBoardState/u
import { ErrorQueueClass } from "components/shared/ErrorQueue/ErrorQueue";
import React from "react";
import { useParams } from "react-router-dom";
import { GameApiResponse } from "types/api/game";
import { GameResultApiResponse } from "types/api/gameResult";
import { ReplayGameAPIResponse } from "types/api/replayGame";
import { validateId } from "utils/chess";
import { parsePlayerApiResponse } from "utils/players";
import { useReplayGameActions } from "./useReplayGameActions";
Expand Down Expand Up @@ -65,7 +65,7 @@ export const useReplayGame = () => {
/**
* Updates the state with the response from the API.
*/
const handleReplayFromApiResponse = (response: ReplayGameAPIResponse) => {
const handleReplayFromApiResponse = (response: GameApiResponse) => {
const { moves, players, termination, winner_color } = response;

const color = players.white.is_current_user ? Color.White : Color.Black;
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/types/api/game.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
export type GameApiResponse = {
import { MoveName } from "components/shared/Chess/ChessBoard/ChessLogic/board";
import { GamePlayersApi, PlayersApi } from "./player";

export type GameApiResponse = SimpleGameApiResponse & {
moves: MoveName[];
players: GamePlayersApi;
};

export type SimpleGameApiResponse = {
game_id: number;
players: { white: { username: string }; black: { username: string } };
players: PlayersApi;
termination: GameTermination;
winner_color: GameWinner;
time_control: number;
date: string;
};

export const GAME_WINNER = {
WHITE: "white",
BLACK: "black",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/types/api/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type GamePlayerApi = PlayerApi & {
time: number;
};

type RegisteredPlayerApi = {
export type RegisteredPlayerApi = {
user_type: "registered";
username: string;
};
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/types/api/profile.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Statuses } from "types/friendStatuses";
import { GameApiResponse } from "./game";
import { SimpleGameApiResponse } from "./game";

export type ProfileApiResponse = {
date_joined: string;
games: GameApiResponse[];
games: SimpleGameApiResponse[];
total_games: number;
total_friends: number;
friend_status?: Statuses;
Expand Down
11 changes: 0 additions & 11 deletions frontend/src/types/api/replayGame.ts

This file was deleted.

4 changes: 2 additions & 2 deletions frontend/src/types/game.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { GAME_WINNER } from "./api/game";
import { PlayerApi } from "./api/player";

export const GAME_OUTCOME = {
WON: "WON",
Expand All @@ -7,8 +8,7 @@ export const GAME_OUTCOME = {
} as const;
export type GameOutcome = keyof typeof GAME_OUTCOME;

export type PlayerInfo = {
username: string;
export type PlayerInfo = PlayerApi & {
color: PlayerColor;
};

Expand Down
Loading