refacto : load game

This commit is contained in:
GuillaumeSD
2025-06-10 02:42:43 +02:00
parent 839f87a2e8
commit 5e2d944513
15 changed files with 310 additions and 556 deletions

View File

@@ -1,10 +1,12 @@
import { ChessComRawGameData } from "@/types/chessCom"; import { ChessComGame } from "@/types/chessCom";
import { getPaddedNumber } from "./helpers"; import { getPaddedNumber } from "./helpers";
import { LoadedGame } from "@/types/game";
import { Chess } from "chess.js";
export const getChessComUserRecentGames = async ( export const getChessComUserRecentGames = async (
username: string, username: string,
signal?: AbortSignal signal?: AbortSignal
): Promise<ChessComRawGameData[]> => { ): Promise<LoadedGame[]> => {
const date = new Date(); const date = new Date();
const year = date.getUTCFullYear(); const year = date.getUTCFullYear();
const month = date.getUTCMonth() + 1; const month = date.getUTCMonth() + 1;
@@ -24,7 +26,7 @@ export const getChessComUserRecentGames = async (
throw new Error("Error fetching games from Chess.com"); throw new Error("Error fetching games from Chess.com");
} }
const games: ChessComRawGameData[] = data?.games ?? []; const games: ChessComGame[] = data?.games ?? [];
if (games.length < 50) { if (games.length < 50) {
const previousMonth = month === 1 ? 12 : month - 1; const previousMonth = month === 1 ? 12 : month - 1;
@@ -42,7 +44,8 @@ export const getChessComUserRecentGames = async (
const gamesToReturn = games const gamesToReturn = games
.sort((a, b) => b.end_time - a.end_time) .sort((a, b) => b.end_time - a.end_time)
.slice(0, 50); .slice(0, 50)
.map(formatChessComGame);
return gamesToReturn; return gamesToReturn;
}; };
@@ -58,3 +61,57 @@ export const getChessComUserAvatar = async (
return typeof avatarUrl === "string" ? avatarUrl : null; return typeof avatarUrl === "string" ? avatarUrl : null;
}; };
const formatChessComGame = (data: ChessComGame): LoadedGame => {
const game = new Chess();
game.loadPgn(data.pgn);
return {
id: data.uuid || data.url?.split("/").pop() || data.id,
pgn: data.pgn || "",
white: {
name: data.white?.username || "White",
rating: data.white?.rating || 0,
title: data.white?.title,
},
black: {
name: data.black?.username || "Black",
rating: data.black?.rating || 0,
title: data.black?.title,
},
result: game.getHeaders().Result,
timeControl: getGameTimeControl(data),
date: data.end_time
? new Date(data.end_time * 1000).toLocaleDateString()
: new Date().toLocaleDateString(),
movesNb: game.history().length,
url: data.url,
};
};
const getGameTimeControl = (game: ChessComGame): string | undefined => {
const rawTimeControl = game.time_control;
if (!rawTimeControl) return undefined;
const [firstPart, secondPart] = rawTimeControl.split("+");
if (!firstPart) return undefined;
const timeControl = Number(firstPart);
const increment = secondPart ? `+${secondPart}` : "";
if (timeControl < 60) return `${timeControl}s${increment}`;
if (timeControl < 3600) {
const minutes = Math.floor(timeControl / 60);
const seconds = timeControl % 60;
return seconds
? `${minutes}m${getPaddedNumber(seconds)}s${increment}`
: `${minutes}m${increment}`;
}
const hours = Math.floor(timeControl / 3600);
const minutes = Math.floor((timeControl % 3600) / 60);
return minutes
? `${hours}h${getPaddedNumber(minutes)}m${increment}`
: `${hours}h${increment}`;
};

View File

@@ -3,11 +3,12 @@ import { sortLines } from "./engine/helpers/parseResults";
import { import {
LichessError, LichessError,
LichessEvalBody, LichessEvalBody,
LichessRawGameData, LichessGame,
LichessResponse, LichessResponse,
} from "@/types/lichess"; } from "@/types/lichess";
import { logErrorToSentry } from "./sentry"; import { logErrorToSentry } from "./sentry";
import { formatUciPv } from "./chess"; import { formatUciPv } from "./chess";
import { LoadedGame } from "@/types/game";
export const getLichessEval = async ( export const getLichessEval = async (
fen: string, fen: string,
@@ -58,7 +59,7 @@ export const getLichessEval = async (
export const getLichessUserRecentGames = async ( export const getLichessUserRecentGames = async (
username: string, username: string,
signal?: AbortSignal signal?: AbortSignal
): Promise<LichessRawGameData[]> => { ): Promise<LoadedGame[]> => {
const res = await fetch( const res = await fetch(
`https://lichess.org/api/games/user/${username}?until=${Date.now()}&max=50&pgnInJson=true&sort=dateDesc&clocks=true`, `https://lichess.org/api/games/user/${username}?until=${Date.now()}&max=50&pgnInJson=true&sort=dateDesc&clocks=true`,
{ method: "GET", headers: { accept: "application/x-ndjson" }, signal } { method: "GET", headers: { accept: "application/x-ndjson" }, signal }
@@ -69,12 +70,12 @@ export const getLichessUserRecentGames = async (
} }
const rawData = await res.text(); const rawData = await res.text();
const games: LichessRawGameData[] = rawData const games: LichessGame[] = rawData
.split("\n") .split("\n")
.filter((game) => game.length > 0) .filter((game) => game.length > 0)
.map((game) => JSON.parse(game)); .map((game) => JSON.parse(game));
return games; return games.map(formatLichessGame);
}; };
const fetchLichessEval = async ( const fetchLichessEval = async (
@@ -94,3 +95,33 @@ const fetchLichessEval = async (
return { error: LichessError.NotFound }; return { error: LichessError.NotFound };
} }
}; };
const formatLichessGame = (data: LichessGame): LoadedGame => {
return {
id: data.id,
pgn: data.pgn || "",
white: {
name: data.players.white.user?.name || "White",
rating: data.players.white.rating,
title: data.players.white.user?.title,
},
black: {
name: data.players.black.user?.name || "Black",
rating: data.players.black.rating,
title: data.players.black.user?.title,
},
result: getGameResult(data),
timeControl: `${Math.floor(data.clock?.initial / 60 || 0)}+${data.clock?.increment || 0}`,
date: new Date(data.createdAt || data.lastMoveAt).toLocaleDateString(),
movesNb: data.moves?.split(" ").length || 0,
url: `https://lichess.org/${data.id}`,
};
};
const getGameResult = (data: LichessGame): string => {
if (data.status === "draw") return "1/2-1/2";
if (data.winner) return data.winner === "white" ? "1-0" : "0-1";
return "*";
};

View File

@@ -11,103 +11,13 @@ import {
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useDebounce } from "@/hooks/useDebounce"; import { useDebounce } from "@/hooks/useDebounce";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ChessComGameItem } from "./chess-com-game-item";
import { ChessComRawGameData, NormalizedGameData } from "@/types/chessCom";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { GameItem } from "./gameItem";
interface Props { interface Props {
onSelect: (pgn: string, boardOrientation?: boolean) => void; onSelect: (pgn: string, boardOrientation?: boolean) => void;
} }
// Helper function to normalize Chess.com data
const normalizeChessComData = (
data: ChessComRawGameData
): NormalizedGameData => {
const timeControl = data.time_control + "s" || "unknown";
// Todo Convert from seconds to minutes to time + increment seconds
// Determine result from multiple sources
let gameResult = "*"; // default to ongoing
if (data.result) {
gameResult = data.result;
} else if (data.white?.result && data.black?.result) {
if (data.white.result === "win") {
gameResult = "1-0";
} else if (data.black.result === "win") {
gameResult = "0-1";
} else if (
(data.white.result === "stalemate" &&
data.black.result === "stalemate") ||
(data.white.result === "repetition" &&
data.black.result === "repetition") ||
(data.white.result === "insufficient" &&
data.black.result === "insufficient") ||
(data.white.result === "50move" && data.black.result === "50move") ||
(data.white.result === "agreed" && data.black.result === "agreed")
) {
gameResult = "1/2-1/2";
}
}
//* Function to count moves from PGN. Generated from claude..... :)
const countMovesFromPGN = (pgn: string) => {
if (!pgn) return 0;
// Split PGN into lines and find the moves section (after headers)
const lines = pgn.split("\n");
let movesSection = "";
let inMoves = false;
for (const line of lines) {
if (line.trim() === "" && !inMoves) {
inMoves = true;
continue;
}
if (inMoves) {
movesSection += line + " ";
}
}
// Remove comments in curly braces and square brackets
movesSection = movesSection
.replace(/\{[^}]*\}/g, "")
.replace(/\[[^\]]*\]/g, "");
// Remove result indicators
movesSection = movesSection.replace(/1-0|0-1|1\/2-1\/2|\*/g, "");
// Split by move numbers and count them
// Match pattern like "1." "58." etc.
const moveNumbers = movesSection.match(/\d+\./g);
return moveNumbers ? moveNumbers.length : 0;
};
return {
id: data.uuid || data.url?.split("/").pop() || data.id,
white: {
username: data.white?.username || "White",
rating: data.white?.rating || 0,
title: data.white?.title,
},
black: {
username: data.black?.username || "Black",
rating: data.black?.rating || 0,
title: data.black?.title,
},
result: gameResult,
timeControl: timeControl,
date: data.end_time
? new Date(data.end_time * 1000).toLocaleDateString()
: new Date().toLocaleDateString(),
opening: data.opening?.name || data.eco,
moves: data.pgn ? countMovesFromPGN(data.pgn) : 0,
url: data.url,
};
};
export default function ChessComInput({ onSelect }: Props) { export default function ChessComInput({ onSelect }: Props) {
const [rawStoredValue, setStoredValues] = useLocalStorage<string>( const [rawStoredValue, setStoredValues] = useLocalStorage<string>(
"chesscom-username", "chesscom-username",
@@ -239,23 +149,22 @@ export default function ChessComInput({ onSelect }: Props) {
) : ( ) : (
<List sx={{ width: "100%", maxWidth: 800 }}> <List sx={{ width: "100%", maxWidth: 800 }}>
{games.map((game) => { {games.map((game) => {
const normalizedGame = normalizeChessComData(game);
const perspectiveUserColor = const perspectiveUserColor =
normalizedGame.white.username.toLowerCase() === game.white.name.toLowerCase() ===
chessComUsername.toLowerCase() debouncedUsername.toLowerCase()
? "white" ? "white"
: "black"; : "black";
return ( return (
<ChessComGameItem <GameItem
key={game.uuid} key={game.id}
{...normalizedGame} game={game}
perspectiveUserColor={perspectiveUserColor} perspectiveUserColor={perspectiveUserColor}
onClick={() => { onClick={() => {
updateHistory(debouncedUsername); updateHistory(debouncedUsername);
const boardOrientation = const boardOrientation =
debouncedUsername.toLowerCase() !== debouncedUsername.toLowerCase() !==
game.black?.username?.toLowerCase(); game.black?.name?.toLowerCase();
onSelect(game.pgn, boardOrientation); onSelect(game.pgn, boardOrientation);
}} }}
/> />

View File

@@ -1,105 +0,0 @@
import { Icon } from "@iconify/react";
import { Chip, Tooltip, useTheme } from "@mui/material";
import React from "react";
export const GameResult: React.FC<{
result: string;
perspectiveUserColor: "white" | "black";
}> = ({ result, perspectiveUserColor }) => {
const theme = useTheme();
let color = theme.palette.text.secondary; // Neutral gray for ongoing
let bgColor = theme.palette.action.hover;
let icon = <Icon icon="material-symbols:play-circle-outline" />;
let label = "Game in Progress";
if (result === "1-0") {
// White wins
if (perspectiveUserColor === "white") {
color = theme.palette.success.main; // Success green
bgColor = `${theme.palette.success.main}1A`; // 10% opacity
icon = <Icon icon="material-symbols:emoji-events" />;
} else {
// perspectiveUserColor is black
color = theme.palette.error.main; // Confident red
bgColor = `${theme.palette.error.main}1A`; // 10% opacity
icon = <Icon icon="material-symbols:sentiment-dissatisfied" />; // A suitable icon for loss
}
label = "White Wins";
} else if (result === "0-1") {
// Black wins
if (perspectiveUserColor === "black") {
color = theme.palette.success.main; // Success green
bgColor = `${theme.palette.success.main}1A`; // 10% opacity
icon = <Icon icon="material-symbols:emoji-events" />;
} else {
// perspectiveUserColor is white
color = theme.palette.error.main; // Confident red
bgColor = `${theme.palette.error.main}1A`; // 10% opacity
icon = <Icon icon="material-symbols:sentiment-dissatisfied" />; // A suitable icon for loss
}
label = "Black Wins";
} else if (result === "1/2-1/2") {
color = theme.palette.info.main; // Balanced blue (using info for a neutral, distinct color)
bgColor = `${theme.palette.info.main}1A`; // 10% opacity
icon = <Icon icon="material-symbols:handshake" />;
label = "Draw";
}
return (
<Tooltip title={label}>
<Chip
icon={icon}
label={result}
size="small"
sx={{
color,
backgroundColor: bgColor,
fontWeight: "600",
minWidth: 65,
border: `1px solid ${color}20`,
"& .MuiChip-icon": {
color: color,
},
}}
/>
</Tooltip>
);
};
export const TimeControlChip: React.FC<{ timeControl: string }> = ({
timeControl,
}) => {
return (
<Tooltip title="Time Control">
<Chip
icon={<Icon icon="material-symbols:timer-outline" />}
label={timeControl}
size="small"
/>
</Tooltip>
);
};
export const MovesChip: React.FC<{ moves: number }> = ({ moves }) => {
return (
<Tooltip title="Number of Moves">
<Chip
icon={<Icon icon="heroicons:hashtag-20-solid" />}
label={`${Math.round(moves / 2)} moves`}
size="small"
/>
</Tooltip>
);
};
export const DateChip: React.FC<{ date: string }> = ({ date }) => {
return (
<Tooltip title="Date Played">
<Chip
icon={<Icon icon="material-symbols:calendar-today" />}
label={date}
size="small"
/>
</Tooltip>
);
};

View File

@@ -0,0 +1,20 @@
import { Icon } from "@iconify/react";
import { Chip, Tooltip } from "@mui/material";
interface Props {
date?: string;
}
export default function DateChip({ date }: Props) {
if (!date) return null;
return (
<Tooltip title="Date Played">
<Chip
icon={<Icon icon="material-symbols:calendar-today" />}
label={date}
size="small"
/>
</Tooltip>
);
}

View File

@@ -0,0 +1,86 @@
import { Icon } from "@iconify/react";
import { Chip, Theme, Tooltip, useTheme } from "@mui/material";
import React from "react";
interface Props {
result?: string;
perspectiveUserColor: "white" | "black";
}
export default function GameResultChip({
result,
perspectiveUserColor,
}: Props) {
const theme = useTheme();
const { label, color, bgColor, icon } = getResultSpecs(
theme,
perspectiveUserColor,
result
);
return (
<Tooltip title={label}>
<Chip
icon={icon}
label={result}
size="small"
sx={{
color,
backgroundColor: bgColor,
fontWeight: "600",
minWidth: icon ? 65 : 40,
border: `1px solid ${color}20`,
"& .MuiChip-icon": {
color: color,
},
}}
/>
</Tooltip>
);
}
const getResultSpecs = (
theme: Theme,
perspectiveUserColor: "white" | "black",
result?: string
) => {
if (
(result === "1-0" && perspectiveUserColor === "white") ||
(result === "0-1" && perspectiveUserColor === "black")
) {
return {
label: result === "1-0" ? "White won" : "Black won",
color: theme.palette.success.main,
bgColor: `${theme.palette.success.main}1A`,
icon: <Icon icon="material-symbols:emoji-events" />,
};
}
if (
(result === "1-0" && perspectiveUserColor === "black") ||
(result === "0-1" && perspectiveUserColor === "white")
) {
return {
label: result === "1-0" ? "White won" : "Black won",
color: theme.palette.error.main,
bgColor: `${theme.palette.error.main}1A`,
};
}
if (result === "1/2-1/2") {
return {
label: "Draw",
color: theme.palette.info.main,
bgColor: `${theme.palette.info.main}1A`,
icon: <Icon icon="material-symbols:handshake" />,
};
}
return {
label: "Game in Progress",
color: theme.palette.text.secondary,
bgColor: theme.palette.action.hover,
icon: <Icon icon="material-symbols:play-circle-outline" />,
};
};

View File

@@ -5,55 +5,26 @@ import {
Typography, Typography,
Box, Box,
useTheme, useTheme,
IconButton,
Tooltip,
} from "@mui/material"; } from "@mui/material";
import { Icon } from "@iconify/react"; import { LoadedGame } from "@/types/game";
import { import TimeControlChip from "./timeControlChip";
DateChip, import MovesNbChip from "./movesNbChip";
GameResult, import DateChip from "./dateChip";
MovesChip, import GameResultChip from "./gameResultChip";
TimeControlChip,
} from "./game-item-utils";
type ChessComPlayer = { interface Props {
username: string; game: LoadedGame;
rating: number;
title?: string;
};
type ChessComGameProps = {
id: string;
white: ChessComPlayer;
black: ChessComPlayer;
result: string;
timeControl: string;
date: string;
opening?: string;
moves?: number;
url: string;
onClick?: () => void; onClick?: () => void;
perspectiveUserColor: "white" | "black"; perspectiveUserColor: "white" | "black";
}; }
export const ChessComGameItem: React.FC<ChessComGameProps> = ({ export const GameItem: React.FC<Props> = ({
white, game,
black,
result,
timeControl,
date,
moves,
url,
onClick, onClick,
perspectiveUserColor, perspectiveUserColor,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const { white, black, result, timeControl, date, movesNb } = game;
const formatPlayerName = (player: ChessComPlayer) => {
return player.title
? `${player.title} ${player.username}`
: player.username;
};
const whiteWon = result === "1-0"; const whiteWon = result === "1-0";
const blackWon = result === "0-1"; const blackWon = result === "0-1";
@@ -122,7 +93,7 @@ export const ChessComGameItem: React.FC<ChessComGameProps> = ({
{formatPlayerName(black)} ({black.rating}) {formatPlayerName(black)} ({black.rating})
</Typography> </Typography>
<GameResult <GameResultChip
result={result} result={result}
perspectiveUserColor={perspectiveUserColor} perspectiveUserColor={perspectiveUserColor}
/> />
@@ -138,14 +109,14 @@ export const ChessComGameItem: React.FC<ChessComGameProps> = ({
}} }}
> >
<TimeControlChip timeControl={timeControl} /> <TimeControlChip timeControl={timeControl} />
{moves && moves > 0 && <MovesChip moves={moves} />} <MovesNbChip movesNb={movesNb} />
<DateChip date={date} /> <DateChip date={date} />
</Box> </Box>
} }
sx={{ mr: 2 }} sx={{ mr: 2 }}
/> />
<Box sx={{ display: "flex", alignItems: "center", ml: "auto" }}> {/* <Box sx={{ display: "flex", alignItems: "center", ml: "auto" }}>
<Tooltip title="View on Chess.com"> <Tooltip title="View on Chess.com">
<IconButton <IconButton
onClick={(e) => { onClick={(e) => {
@@ -165,7 +136,11 @@ export const ChessComGameItem: React.FC<ChessComGameProps> = ({
<Icon icon="material-symbols:open-in-new" /> <Icon icon="material-symbols:open-in-new" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box> */}
</ListItem> </ListItem>
); );
}; };
const formatPlayerName = (player: LoadedGame["white"]) => {
return player.title ? `${player.title} ${player.name}` : player.name;
};

View File

@@ -0,0 +1,20 @@
import { Icon } from "@iconify/react";
import { Chip, Tooltip } from "@mui/material";
interface Props {
movesNb?: number;
}
export default function MovesNbChip({ movesNb }: Props) {
if (!movesNb) return null;
return (
<Tooltip title="Number of Moves">
<Chip
icon={<Icon icon="heroicons:hashtag-20-solid" />}
label={`${Math.ceil(movesNb / 2)} moves`}
size="small"
/>
</Tooltip>
);
}

View File

@@ -0,0 +1,20 @@
import { Icon } from "@iconify/react";
import { Chip, Tooltip } from "@mui/material";
interface Props {
timeControl?: string;
}
export default function TimeControlChip({ timeControl }: Props) {
if (!timeControl) return null;
return (
<Tooltip title="Time Control">
<Chip
icon={<Icon icon="material-symbols:timer-outline" />}
label={timeControl}
size="small"
/>
</Tooltip>
);
}

View File

@@ -10,7 +10,7 @@ interface Props {
export default function GamePgnInput({ pgn, setPgn }: Props) { export default function GamePgnInput({ pgn, setPgn }: Props) {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
@@ -18,8 +18,7 @@ export default function GamePgnInput({ pgn, setPgn }: Props) {
setPgn(fileContent); setPgn(fileContent);
}; };
reader.readAsText(file); // Read the file as text reader.readAsText(file);
}
}; };
return ( return (
@@ -39,12 +38,7 @@ export default function GamePgnInput({ pgn, setPgn }: Props) {
startIcon={<Icon icon="material-symbols:upload" />} startIcon={<Icon icon="material-symbols:upload" />}
> >
Choose PGN File Choose PGN File
<input <input type="file" hidden accept=".pgn" onChange={handleFileChange} />
type="file"
hidden // Hide the default file input
accept=".pgn" // Only allow .pgn files
onChange={handleFileChange}
/>
</Button> </Button>
</FormControl> </FormControl>
); );

View File

@@ -1,179 +0,0 @@
import type React from "react";
import {
ListItem,
ListItemText,
Typography,
Chip,
Box,
useTheme,
IconButton,
Tooltip,
} from "@mui/material";
import { Icon } from "@iconify/react";
import {
DateChip,
GameResult,
MovesChip,
TimeControlChip,
} from "./game-item-utils";
type LichessPlayer = {
username: string;
rating: number;
title?: string;
};
type LichessGameProps = {
id: string;
white: LichessPlayer;
black: LichessPlayer;
result: string;
timeControl: string;
date: string;
opening?: string;
moves?: number;
url: string;
onClick?: () => void;
perspectiveUserColor: "white" | "black";
};
export const LichessGameItem: React.FC<LichessGameProps> = ({
white,
black,
result,
timeControl,
date,
perspectiveUserColor,
moves,
url,
onClick,
}) => {
const theme = useTheme();
// If it is a titled played append the title to the start of the name
const formatPlayerName = (player: LichessPlayer) => {
return player.title
? `${player.title} ${player.username}`
: player.username;
};
const whiteWon = result === "1-0";
const blackWon = result === "0-1";
return (
<ListItem
alignItems="flex-start"
sx={{
borderRadius: 2,
mb: 1.5,
transition: "all 0.2s ease-in-out",
"&:hover": {
backgroundColor: theme.palette.action.hover,
transform: "translateY(-1px)",
boxShadow: theme.shadows[3],
},
border: `1px solid ${theme.palette.divider}`,
cursor: onClick ? "pointer" : "default",
}}
onClick={onClick}
>
<ListItemText
primary={
<Box
sx={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 1.5,
mb: 1,
}}
>
<Typography
variant="subtitle1"
component="span"
sx={{
fontWeight: "700",
color: whiteWon
? theme.palette.success.main
: theme.palette.text.primary,
opacity: blackWon ? 0.7 : 1,
}}
>
{formatPlayerName(white)} ({white.rating})
</Typography>
<Typography
variant="body2"
component="span"
sx={{ color: theme.palette.text.secondary, fontWeight: "500" }}
>
vs
</Typography>
<Typography
variant="subtitle1"
component="span"
sx={{
fontWeight: "700",
color: blackWon
? theme.palette.success.main
: theme.palette.text.primary,
opacity: whiteWon ? 0.7 : 1,
}}
>
{formatPlayerName(black)} ({black.rating})
</Typography>
<GameResult
result={result}
perspectiveUserColor={perspectiveUserColor}
/>
</Box>
}
secondary={
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 1,
alignItems: "center",
}}
>
<TimeControlChip timeControl={timeControl} />
{moves && moves > 0 && <MovesChip moves={moves} />}
<DateChip date={date} />
<Chip
icon={<Icon icon="simple-icons:lichess" />}
label="Lichess"
size="small"
/>
</Box>
}
sx={{ mr: 2 }}
/>
<Box sx={{ display: "flex", alignItems: "center", ml: "auto" }}>
<Tooltip title="View on Lichess">
<IconButton
onClick={(e) => {
e.stopPropagation();
window.open(url, "_blank");
}}
size="small"
sx={{
color: theme.palette.primary.main,
"&:hover": {
backgroundColor: theme.palette.action.hover,
transform: "scale(1.1)",
},
transition: "all 0.2s ease-in-out",
}}
>
<Icon icon="material-symbols:open-in-new" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
);
};

View File

@@ -1,5 +1,3 @@
"use client";
import { useLocalStorage } from "@/hooks/useLocalStorage"; import { useLocalStorage } from "@/hooks/useLocalStorage";
import { getLichessUserRecentGames } from "@/lib/lichess"; import { getLichessUserRecentGames } from "@/lib/lichess";
import { import {
@@ -13,44 +11,13 @@ import {
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useDebounce } from "@/hooks/useDebounce"; import { useDebounce } from "@/hooks/useDebounce";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { LichessGameItem } from "./lichess-game-item";
import { LichessRawGameData, NormalizedLichessGameData } from "@/types/lichess";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { GameItem } from "./gameItem";
interface Props { interface Props {
onSelect: (pgn: string, boardOrientation?: boolean) => void; onSelect: (pgn: string, boardOrientation?: boolean) => void;
} }
// Helper function to normalize Lichess data
const normalizeLichessData = (
data: LichessRawGameData
): NormalizedLichessGameData => ({
id: data.id,
white: {
username: data.players.white.user?.name || "Anonymous",
rating: data.players.white.rating,
title: data.players.white.user?.title,
},
black: {
username: data.players.black.user?.name || "Anonymous",
rating: data.players.black.rating,
title: data.players.black.user?.title,
},
result:
data.status === "draw"
? "1/2-1/2"
: data.winner
? data.winner === "white"
? "1-0"
: "0-1"
: "*",
timeControl: `${Math.floor(data.clock?.initial / 60 || 0)}+${data.clock?.increment || 0}`,
date: new Date(data.createdAt || data.lastMoveAt).toLocaleDateString(),
opening: data.opening?.name,
moves: data.moves?.split(" ").length || 0,
url: `https://lichess.org/${data.id}`,
});
export default function LichessInput({ onSelect }: Props) { export default function LichessInput({ onSelect }: Props) {
const [rawStoredValue, setStoredValues] = useLocalStorage<string>( const [rawStoredValue, setStoredValues] = useLocalStorage<string>(
"lichess-username", "lichess-username",
@@ -181,24 +148,23 @@ export default function LichessInput({ onSelect }: Props) {
) : ( ) : (
<List sx={{ width: "100%", maxWidth: 800 }}> <List sx={{ width: "100%", maxWidth: 800 }}>
{games.map((game) => { {games.map((game) => {
const normalizedGame = normalizeLichessData(game);
const perspectiveUserColor = const perspectiveUserColor =
normalizedGame.white.username.toLowerCase() === game.white.name.toLowerCase() ===
lichessUsername.toLowerCase() debouncedUsername.toLowerCase()
? "white" ? "white"
: "black"; : "black";
return ( return (
<LichessGameItem <GameItem
key={game.id} key={game.id}
{...normalizedGame} game={game}
perspectiveUserColor={perspectiveUserColor} perspectiveUserColor={perspectiveUserColor}
onClick={() => { onClick={() => {
updateHistory(debouncedUsername);
const boardOrientation = const boardOrientation =
debouncedUsername.toLowerCase() !== debouncedUsername.toLowerCase() !==
game.players?.black?.user?.name?.toLowerCase(); game.black.name.toLowerCase();
onSelect(game.pgn, boardOrientation); onSelect(game.pgn, boardOrientation);
updateHistory(debouncedUsername);
}} }}
/> />
); );

View File

@@ -1,46 +1,20 @@
interface ChessComPlayerData { interface ChessComPlayer {
username: string; username: string;
rating: number; rating: number;
result?: string; result?: string;
title?: string; title?: string;
} }
interface ChessComOpeningData { export interface ChessComGame {
name: string;
eco?: string;
}
export interface ChessComRawGameData {
uuid: string; uuid: string;
id: string; id: string;
url: string; url: string;
pgn: string; pgn: string;
white: ChessComPlayerData; white: ChessComPlayer;
black: ChessComPlayerData; black: ChessComPlayer;
result: string; result: string;
time_control: string; time_control: string;
end_time: number; end_time: number;
opening?: ChessComOpeningData;
eco?: string; eco?: string;
termination?: string; termination?: string;
} }
export interface NormalizedGameData {
id: string;
white: {
username: string;
rating: number;
title?: string;
};
black: {
username: string;
rating: number;
title?: string;
};
result: string;
timeControl: string;
date: string;
opening?: string;
moves: number;
url: string;
}

View File

@@ -19,4 +19,17 @@ export interface Player {
name: string; name: string;
rating?: number; rating?: number;
avatarUrl?: string; avatarUrl?: string;
title?: string;
}
export interface LoadedGame {
id: string;
pgn: string;
date?: string;
white: Player;
black: Player;
result?: string;
timeControl?: string;
movesNb?: number;
url?: string;
} }

View File

@@ -17,7 +17,7 @@ export enum LichessError {
NotFound = "No cloud evaluation available for that position", NotFound = "No cloud evaluation available for that position",
} }
interface LichessPlayerData { interface LichessPlayer {
user: { user: {
name: string; name: string;
title?: string; title?: string;
@@ -25,51 +25,24 @@ interface LichessPlayerData {
rating: number; rating: number;
} }
interface LichessClockData { interface LichessClock {
initial: number; initial: number;
increment: number; increment: number;
totalTime: number; totalTime: number;
} }
interface LichessOpeningData { export interface LichessGame {
eco: string;
name: string;
ply: number;
}
export interface LichessRawGameData {
id: string; id: string;
createdAt: number; createdAt: number;
lastMoveAt: number; lastMoveAt: number;
status: string; status: string;
players: { players: {
white: LichessPlayerData; white: LichessPlayer;
black: LichessPlayerData; black: LichessPlayer;
}; };
winner?: "white" | "black"; winner?: "white" | "black";
opening?: LichessOpeningData;
moves: string; moves: string;
pgn: string; pgn: string;
clock: LichessClockData; clock: LichessClock;
url?: string; url?: string;
} }
export interface NormalizedLichessGameData {
id: string;
white: {
username: string;
rating: number;
title?: string;
};
black: {
username: string;
rating: number;
title?: string;
};
result: string;
timeControl: string;
date: string;
opening?: string;
moves: number;
url: string;
}