From 49d3582083f340a940c481421f74bde1981917c3 Mon Sep 17 00:00:00 2001 From: Fabio Buracchi <45599613+buracchi@users.noreply.github.com> Date: Sun, 15 Dec 2024 19:33:38 +0100 Subject: [PATCH] Add personal card ratings panel and minor improvements - Add new components for personal card panels. - Fix timezone ISO string bug in the overview panel. - Remove meaningless ELO average calculations between different rating systems. - Add some filters for both overview and openings panels. - Improve overall code readability and structure. --- src-tauri/src/db/mod.rs | 293 ++++++------ src/bindings/generated.ts | 7 +- src/components/home/Databases.tsx | 75 +--- src/components/home/PersonalCard.tsx | 423 +----------------- .../home/PersonalCardPanels/DateRangeTabs.tsx | 43 ++ .../OpeningsPanel.css.ts} | 0 .../home/PersonalCardPanels/OpeningsPanel.tsx | 222 +++++++++ .../home/PersonalCardPanels/OverviewPanel.tsx | 203 +++++++++ .../PersonalCardPanels/RatingsPanel.css.ts | 25 ++ .../home/PersonalCardPanels/RatingsPanel.tsx | 210 +++++++++ .../home/PersonalCardPanels/ResultsChart.tsx | 50 +++ .../TimeControlSelector.tsx | 61 +++ .../PersonalCardPanels/TimeRangeSlider.tsx | 47 ++ .../WebsiteAccountSelector.tsx | 84 ++++ src/utils/db.ts | 11 - src/utils/timeControl.ts | 28 ++ 16 files changed, 1137 insertions(+), 645 deletions(-) create mode 100644 src/components/home/PersonalCardPanels/DateRangeTabs.tsx rename src/components/home/{PersonalCard.css.ts => PersonalCardPanels/OpeningsPanel.css.ts} (100%) create mode 100644 src/components/home/PersonalCardPanels/OpeningsPanel.tsx create mode 100644 src/components/home/PersonalCardPanels/OverviewPanel.tsx create mode 100644 src/components/home/PersonalCardPanels/RatingsPanel.css.ts create mode 100644 src/components/home/PersonalCardPanels/RatingsPanel.tsx create mode 100644 src/components/home/PersonalCardPanels/ResultsChart.tsx create mode 100644 src/components/home/PersonalCardPanels/TimeControlSelector.tsx create mode 100644 src/components/home/PersonalCardPanels/TimeRangeSlider.tsx create mode 100644 src/components/home/PersonalCardPanels/WebsiteAccountSelector.tsx create mode 100644 src/utils/timeControl.ts diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index f6f2fc2d..b4bb6845 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -35,7 +35,7 @@ use specta::Type; use std::{ fs::{remove_file, File, OpenOptions}, path::PathBuf, - sync::atomic::{AtomicI32, AtomicUsize, Ordering}, + sync::atomic::{AtomicUsize, Ordering}, time::{Duration, Instant}, }; use std::{ @@ -1123,27 +1123,52 @@ pub async fn get_tournaments( #[derive(Debug, Clone, Serialize, Type, Default)] pub struct PlayerGameInfo { - pub won: i32, - pub lost: i32, - pub draw: i32, - pub data_per_month: Vec<(String, MonthData)>, - pub white_openings: Vec<(String, Results)>, - pub black_openings: Vec<(String, Results)>, + pub site_stats_data: Vec, } -#[derive(Debug, Clone, Serialize, Type, Default, Eq, Ord, PartialEq, PartialOrd)] -pub struct Results { - pub won: i32, - pub lost: i32, - pub draw: i32, +#[derive(Debug, Clone, Serialize, Deserialize, Default, Type)] +#[repr(u8)] // Ensure minimal memory usage (as u8) +pub enum GameOutcome { + #[default] + Won = 0, + Drawn = 1, + Lost = 2, +} + +impl GameOutcome { + pub fn from_str(result_str: &str, is_white: bool) -> Option { + match result_str { + "1-0" => Some(if is_white { + GameOutcome::Won + } else { + GameOutcome::Lost + }), + "1/2-1/2" => Some(GameOutcome::Drawn), + "0-1" => Some(if is_white { + GameOutcome::Lost + } else { + GameOutcome::Won + }), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Type, Default)] +pub struct SiteStatsData { + pub site: String, + pub player: String, + pub data: Vec, } #[derive(Debug, Clone, Serialize, Type, Default)] -pub struct MonthData { - pub count: i32, - pub avg_elo: i32, - #[serde(skip)] - avg_count: i32, +pub struct StatsData { + pub date: String, + pub is_player_white: bool, + pub player_elo: i32, + pub result: GameOutcome, + pub time_control: String, + pub opening: String, } #[derive(Serialize, Debug, Clone, Type, tauri_specta::Event)] @@ -1164,6 +1189,8 @@ pub async fn get_players_game_info( let timer = Instant::now(); let sql_query = games::table + .inner_join(sites::table.on(games::site_id.eq(sites::id))) + .inner_join(players::table.on(players::id.eq(id))) .select(( games::white_id, games::black_id, @@ -1172,6 +1199,9 @@ pub async fn get_players_game_info( games::moves, games::white_elo, games::black_elo, + games::time_control, + sites::name, + players::name, )) .filter(games::white_id.eq(id).or(games::black_id.eq(id))) .filter(games::fen.is_null()); @@ -1184,159 +1214,116 @@ pub async fn get_players_game_info( Vec, Option, Option, + Option, + Option, + Option, ); let info: Vec = sql_query.load(db)?; let mut game_info = PlayerGameInfo::default(); - let white_openings = DashMap::new(); - let black_openings = DashMap::new(); - let won = AtomicI32::new(0); - let lost = AtomicI32::new(0); - let draw = AtomicI32::new(0); - let data_per_month = DashMap::new(); let progress = AtomicUsize::new(0); - - info.par_iter().for_each( - |(white_id, black_id, outcome, date, moves, white_elo, black_elo)| { - let is_white = *white_id == id; - assert!(is_white || *black_id == id); - - let mut setups = vec![]; - let mut chess = Chess::default(); - for (i, byte) in moves.iter().enumerate() { - if i > 54 { - // max length of opening in data - break; + game_info.site_stats_data = info + .par_iter() + .filter_map( + |( + white_id, + black_id, + outcome, + date, + moves, + white_elo, + black_elo, + time_control, + site, + player, + )| { + let is_white = *white_id == id; + let is_black = *black_id == id; + let result = GameOutcome::from_str(outcome.as_deref()?, is_white); + + if !is_white && !is_black + || is_white && white_elo.is_none() + || is_black && black_elo.is_none() + || result.is_none() + || date.is_none() + || site.is_none() + || player.is_none() + { + return None; } - let m = decode_move(*byte, &chess).unwrap(); - chess.play_unchecked(&m); - setups.push(chess.clone().into_setup(EnPassantMode::Legal)); - } - setups.reverse(); - for setup in setups { - if let Ok(opening) = get_opening_from_setup(setup) { - let openings = if is_white { - &white_openings + let site = site.as_deref().map(|s| { + if s.starts_with("https://lichess.org/") { + "Lichess".to_string() } else { - &black_openings - }; - if outcome.as_deref() == Some("1-0") { - openings - .entry(opening) - .and_modify(|e: &mut Results| { - if is_white { - e.won += 1; - } else { - e.lost += 1; - } - }) - .or_insert(Results { - won: 1, - lost: 0, - draw: 0, - }); - } else if outcome.as_deref() == Some("0-1") { - openings - .entry(opening) - .and_modify(|e| { - if is_white { - e.lost += 1; - } else { - e.won += 1; - } - }) - .or_insert(Results { - won: 0, - lost: 1, - draw: 0, - }); - } else if outcome.as_deref() == Some("1/2-1/2") { - openings - .entry(opening) - .and_modify(|e| { - e.draw += 1; - }) - .or_insert(Results { - won: 0, - lost: 0, - draw: 1, - }); + s.to_string() } - - break; + })?; + + let mut setups = vec![]; + let mut chess = Chess::default(); + for (i, byte) in moves.iter().enumerate() { + if i > 54 { + // max length of opening in data + break; + } + let m = decode_move(*byte, &chess).unwrap(); + chess.play_unchecked(&m); + setups.push(chess.clone().into_setup(EnPassantMode::Legal)); } - } - if let Some(date) = date { - let date = match NaiveDate::parse_from_str(date, "%Y.%m.%d") { - Ok(date) => date, - Err(_) => return, - }; - let month = date.format("%Y-%m").to_string(); - - // update count and avg elo - let mut month_data = data_per_month.entry(month).or_insert(MonthData::default()); - month_data.count += 1; - let elo = if is_white { white_elo } else { black_elo }; - if let Some(elo) = elo { - month_data.avg_elo += elo; - month_data.avg_count += 1; + setups.reverse(); + let opening = setups + .iter() + .find_map(|setup| get_opening_from_setup(setup.clone()).ok()) + .unwrap_or_default(); + + let p = progress.fetch_add(1, Ordering::Relaxed); + if p % 1000 == 0 || p == info.len() - 1 { + let _ = DatabaseProgress { + id: id.to_string(), + progress: (p as f64 / info.len() as f64) * 100_f64, + } + .emit(&app); } - } - match outcome.as_deref() { - Some("1-0") => match is_white { - true => won.fetch_add(1, Ordering::Relaxed), - false => lost.fetch_add(1, Ordering::Relaxed), - }, - Some("0-1") => match is_white { - true => lost.fetch_add(1, Ordering::Relaxed), - false => won.fetch_add(1, Ordering::Relaxed), - }, - Some("1/2-1/2") => draw.fetch_add(1, Ordering::Relaxed), - _ => 0, - }; - let p = progress.fetch_add(1, Ordering::Relaxed); - if p % 1000 == 0 || p == info.len() - 1 { - let _ = DatabaseProgress { - id: id.to_string(), - progress: (p as f64 / info.len() as f64) * 100_f64, + Some(SiteStatsData { + site: site.clone(), + player: player.clone().unwrap(), + data: vec![StatsData { + date: date.clone().unwrap(), + is_player_white: is_white, + player_elo: if is_white { + white_elo.unwrap() + } else { + black_elo.unwrap() + }, + result: result.unwrap(), + time_control: time_control.clone().unwrap_or_default(), + opening, + }], + }) + }, + ) + .fold(|| DashMap::new(), |acc, data| { + acc.entry((data.site.clone(), data.player.clone())) + .or_insert_with(Vec::new) + .extend(data.data); + acc + }) + .reduce(|| DashMap::new(), |acc1, acc2| { + for ((site, player), data) in acc2 { + acc1.entry((site, player)) + .or_insert_with(Vec::new) + .extend(data); } - .emit(&app); - } - }, - ); - game_info.white_openings = white_openings.into_iter().collect(); - game_info.black_openings = black_openings.into_iter().collect(); - game_info.won = won.into_inner(); - game_info.lost = lost.into_inner(); - game_info.draw = draw.into_inner(); - game_info.data_per_month = data_per_month.into_iter().collect(); - game_info.data_per_month = game_info - .data_per_month + acc1 + }, + ) .into_iter() - .map(|(month, data)| { - let avg_elo = if data.avg_count == 0 { - 0 - } else { - data.avg_elo / data.avg_count - }; - ( - month, - MonthData { - count: data.count, - avg_elo, - avg_count: data.avg_count, - }, - ) - }) + .map(|((site, player), data)| SiteStatsData { site, player, data }) .collect(); - // sort openings by count - game_info.white_openings.sort_by(|(_, a), (_, b)| b.cmp(a)); - game_info.black_openings.sort_by(|(_, a), (_, b)| b.cmp(a)); - println!("get_players_game_info {:?}: {:?}", file, timer.elapsed()); Ok(game_info) diff --git a/src/bindings/generated.ts b/src/bindings/generated.ts index 4c8f1bfb..949f9998 100644 --- a/src/bindings/generated.ts +++ b/src/bindings/generated.ts @@ -386,16 +386,16 @@ export type EngineOptions = { fen: string; moves: string[]; extraOptions: Engine export type Event = { id: number; name: string | null } export type FidePlayer = { fideid: number; name: string; country: string; sex: string; title: string | null; w_title: string | null; o_title: string | null; foa_title: string | null; rating: number | null; games: number | null; k: number | null; rapid_rating: number | null; rapid_games: number | null; rapid_k: number | null; blitz_rating: number | null; blitz_games: number | null; blitz_k: number | null; birthday: number | null; flag: string | null } export type FileMetadata = { last_modified: number } +export type GameOutcome = "Won" | "Drawn" | "Lost" export type GameQueryJs = { options?: QueryOptions | null; player1?: number | null; player2?: number | null; tournament_id?: number | null; start_date?: string | null; end_date?: string | null; range1?: [number, number] | null; range2?: [number, number] | null; sides?: Sides | null; outcome?: string | null; position?: PositionQueryJs | null } export type GameSort = "id" | "date" | "whiteElo" | "blackElo" | "ply_count" export type GoMode = { t: "PlayersTime"; c: PlayersTime } | { t: "Depth"; c: number } | { t: "Time"; c: number } | { t: "Nodes"; c: number } | { t: "Infinite" } -export type MonthData = { count: number; avg_elo: number } export type MoveAnalysis = { best: BestMoves[]; novelty: boolean; is_sacrifice: boolean } export type NormalizedGame = { id: number; fen: string; event: string; event_id: number; site: string; site_id: number; date?: string | null; time?: string | null; round?: string | null; white: string; white_id: number; white_elo?: number | null; black: string; black_id: number; black_elo?: number | null; result: Outcome; time_control?: string | null; eco?: string | null; ply_count?: number | null; moves: string } export type OutOpening = { name: string; fen: string } export type Outcome = "1-0" | "0-1" | "1/2-1/2" | "*" export type Player = { id: number; name: string | null; elo: number | null } -export type PlayerGameInfo = { won: number; lost: number; draw: number; data_per_month: ([string, MonthData])[]; white_openings: ([string, Results])[]; black_openings: ([string, Results])[] } +export type PlayerGameInfo = { site_stats_data: SiteStatsData[] } export type PlayerQuery = { options: QueryOptions; name?: string | null; range?: [number, number] | null } export type PlayerSort = "id" | "name" | "elo" export type PlayersTime = { white: number; black: number; winc: number; binc: number } @@ -406,7 +406,6 @@ export type PuzzleDatabaseInfo = { title: string; description: string; puzzleCou export type QueryOptions = { skipCount: boolean; page?: number | null; pageSize?: number | null; sort: SortT; direction: SortDirection } export type QueryResponse = { data: T; count: number | null } export type ReportProgress = { progress: number; id: string; finished: boolean } -export type Results = { won: number; lost: number; draw: number } export type Score = { value: ScoreValue; /** * The probability of each result (win, draw, loss). @@ -422,7 +421,9 @@ export type ScoreValue = */ { type: "mate"; value: number } export type Sides = "BlackWhite" | "WhiteBlack" | "Any" +export type SiteStatsData = { site: string; player: string; data: StatsData[] } export type SortDirection = "asc" | "desc" +export type StatsData = { date: string; is_player_white: boolean; player_elo: number; result: GameOutcome; time_control: string; opening: string } export type Token = { type: "ParenOpen" } | { type: "ParenClose" } | { type: "Comment"; value: string } | { type: "San"; value: string } | { type: "Header"; value: { tag: string; value: string } } | { type: "Nag"; value: string } | { type: "Outcome"; value: string } export type TournamentQuery = { options: QueryOptions; name: string | null } export type TournamentSort = "id" | "name" diff --git a/src/components/home/Databases.tsx b/src/components/home/Databases.tsx index 6cdaa453..b3174ba6 100644 --- a/src/components/home/Databases.tsx +++ b/src/components/home/Databases.tsx @@ -1,7 +1,7 @@ -import { events, type MonthData, type Results, commands } from "@/bindings"; -import type { DatabaseInfo as PlainDatabaseInfo, Player } from "@/bindings"; +import { events, commands } from "@/bindings"; +import type { DatabaseInfo as PlainDatabaseInfo, PlayerGameInfo } from "@/bindings"; import { sessionsAtom } from "@/state/atoms"; -import { type PlayerGameInfo, getDatabases, query_players } from "@/utils/db"; +import { getDatabases, query_players } from "@/utils/db"; import type { Session } from "@/utils/session"; import { unwrap } from "@/utils/unwrap"; import { Flex, Progress, Select, Text } from "@mantine/core"; @@ -39,70 +39,6 @@ interface PersonalInfo { info: PlayerGameInfo; } -function sumGamesPlayed(lists: [string, Results][][]) { - const openingCounts = new Map(); - - for (const list of lists) { - for (const [opening, count] of list) { - const prev = openingCounts.get(opening) || { won: 0, draw: 0, lost: 0 }; - openingCounts.set(opening, { - won: prev.won + count.won, - draw: prev.draw + count.draw, - lost: prev.lost + count.lost, - }); - } - } - - return Array.from(openingCounts.entries()).sort( - (a, b) => - b[1].won + b[1].draw + b[1].lost - a[1].won - a[1].draw - a[1].lost, - ); -} - -function joinMonthData(data: [string, MonthData][][]) { - const monthCounts = new Map(); - - for (const list of data) { - for (const [month, monthData] of list) { - if (monthCounts.has(month)) { - const oldData = monthCounts.get(month); - if (oldData) { - monthCounts.set(month, { - count: oldData.count + monthData.count, - avg_elo: oldData.avg_elo + monthData.avg_elo, - avg_count: oldData.avg_count + 1, - }); - } - } else { - monthCounts.set(month, { ...monthData, avg_count: 1 }); - } - } - } - for (const [month, monthData] of monthCounts) { - monthCounts.set(month, { - count: monthData.count, - avg_elo: monthData.avg_elo / monthData.avg_count, - avg_count: monthData.avg_count, - }); - } - - return Array.from(monthCounts.entries()).sort((a, b) => - a[0].localeCompare(b[0]), - ); -} - -function combinePlayerInfo(playerInfos: PlayerGameInfo[]) { - const combined: PlayerGameInfo = { - won: playerInfos.reduce((acc, i) => acc + i.won, 0), - lost: playerInfos.reduce((acc, i) => acc + i.lost, 0), - draw: playerInfos.reduce((acc, i) => acc + i.draw, 0), - data_per_month: joinMonthData(playerInfos.map((i) => i.data_per_month)), - white_openings: sumGamesPlayed(playerInfos.map((i) => i.white_openings)), - black_openings: sumGamesPlayed(playerInfos.map((i) => i.black_openings)), - }; - return combined; -} - function Databases() { const sessions = useAtomValue(sessionsAtom); @@ -233,7 +169,10 @@ function Databases() { i.info))} + info={{ + site_stats_data: personalInfo + .flatMap((i) => i.info.site_stats_data) + }} /> ))} diff --git a/src/components/home/PersonalCard.tsx b/src/components/home/PersonalCard.tsx index d0b74a39..daeefd8c 100644 --- a/src/components/home/PersonalCard.tsx +++ b/src/components/home/PersonalCard.tsx @@ -1,120 +1,22 @@ -import { type MonthData, commands } from "@/bindings"; -import { - activeTabAtom, - fontSizeAtom, - sessionsAtom, - tabsAtom, -} from "@/state/atoms"; -import { parsePGN } from "@/utils/chess"; -import type { PlayerGameInfo } from "@/utils/db"; -import { createTab } from "@/utils/tabs"; -import { countMainPly, defaultTree } from "@/utils/treeReducer"; -import { unwrap } from "@/utils/unwrap"; +import { useState } from "react"; +import { useAtomValue } from "jotai"; +import { sessionsAtom } from "@/state/atoms"; +import type { PlayerGameInfo } from "@/bindings"; import { ActionIcon, Box, - Divider, Flex, - Group, Tooltip as MTTooltip, Paper, - Progress, - ScrollArea, Select, - Stack, - Table, Tabs, Text, - useMantineColorScheme, - useMantineTheme, } from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; -import { useNavigate } from "@tanstack/react-router"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import type { Color } from "chessops"; -import { useAtom, useAtomValue } from "jotai"; -import { useRef, useState } from "react"; -import { - Bar, - CartesianGrid, - ComposedChart, - Legend, - Line, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; import FideInfo from "../databases/FideInfo"; -import * as classes from "./PersonalCard.css"; - -function fillMissingMonths( - data: { name: string; data: MonthData }[], -): { name: string; data: { count: number; avg_elo: number | null } }[] { - if (data.length === 0) return data; - const startDate = new Date(`${data[0].name}-01`); - const endDate = new Date(`${data[data.length - 1].name}-01`); - const months = []; - const currDate = startDate; - - months.push(currDate.toISOString().slice(0, 7)); - while (currDate <= endDate) { - currDate.setMonth(currDate.getMonth() + 1); - months.push(currDate.toISOString().slice(0, 7)); - } - - const newData = months.map((month) => { - const foundMonth = data.find((obj) => obj.name === month); - if (foundMonth) { - return foundMonth; - } - return { name: month, data: { count: 0, avg_elo: null } }; - }); - - return newData; -} - -function mergeYears( - data: { name: string; data: { count: number; avg_elo: number | null } }[], -) { - // group by year in the same format - const grouped = data.reduce( - (acc, curr) => { - const year = curr.name.slice(0, 4); - if (!acc[year]) { - acc[year] = []; - } - acc[year].push(curr); - return acc; - }, - {} as { - [key: string]: { - name: string; - data: { count: number; avg_elo: number | null }; - }[]; - }, - ); - - // sum up the games per year - const summed = Object.entries(grouped).map(([year, months]) => { - const games = months.reduce((acc, curr) => acc + curr.data.count, 0); - const avg_elo = - months.filter((obj) => obj.data.avg_elo !== null).length > 0 - ? months.reduce((acc, curr) => acc + curr.data.avg_elo!, 0) / - months.filter((obj) => obj.data.avg_elo !== null).length - : null; - return { name: year, data: { count: games, avg_elo } }; - }); - - return summed; -} - -function zip(a: T[], b: T[]) { - return Array.from(Array(Math.max(b.length, a.length)), (_, i) => [ - a[i], - b[i], - ]); -} +import RatingsPanel from "./PersonalCardPanels/RatingsPanel"; +import OverviewPanel from "./PersonalCardPanels/OverviewPanel"; +import OpeningsPanel from "./PersonalCardPanels/OpeningsPanel"; function PersonalPlayerCard({ name, @@ -125,18 +27,6 @@ function PersonalPlayerCard({ setName?: (name: string) => void; info: PlayerGameInfo; }) { - const total = info ? info.won + info.lost + info.draw : 0; - - const white_openings = info?.white_openings ?? []; - const black_openings = info?.black_openings ?? []; - - const whiteGames = white_openings.reduce((acc, cur) => { - return acc + cur[1].won + cur[1].draw + cur[1].lost; - }, 0); - const blackGames = black_openings.reduce((acc, cur) => { - return acc + cur[1].won + cur[1].draw + cur[1].lost; - }, 0); - const [opened, setOpened] = useState(false); const sessions = useAtomValue(sessionsAtom); const players = Array.from( @@ -204,311 +94,24 @@ function PersonalPlayerCard({ > Overview + Ratings Openings - - - {total} Games - - - {total > 0 && ( - <> - - - - - )} - + - + + + + ); } -function OpeningsPanel({ - white_openings, - black_openings, - whiteGames, - blackGames, -}: { - white_openings: [string, { won: number; draw: number; lost: number }][]; - black_openings: [string, { won: number; draw: number; lost: number }][]; - whiteGames: number; - blackGames: number; -}) { - const fontSize = useAtomValue(fontSizeAtom); - - const parentRef = useRef(null); - const rowVirtualizer = useVirtualizer({ - count: Math.max(white_openings.length, black_openings.length), - estimateSize: () => 120 * (fontSize / 100), - getScrollElement: () => parentRef.current, - }); - - return ( - - - - White - - - Black - - - - - - {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const white = white_openings[virtualRow.index]; - const black = black_openings[virtualRow.index]; - return ( - - - {white ? ( - - - - - {( - ((white[1].won + white[1].draw + white[1].lost) / - whiteGames) * - 100 - ).toFixed(2)} - % - - - - - ) : ( -
- )} - {black ? ( - - - - - {( - ((black[1].won + black[1].draw + black[1].lost) / - blackGames) * - 100 - ).toFixed(2)} - % - - - - - ) : ( -
- )} - - - - ); - })} - - - - ); -} - -function DateChart({ - data_per_month, -}: { - data_per_month: [string, MonthData][]; -}) { - const [selectedYear, setSelectedYear] = useState(null); - - let data = fillMissingMonths( - data_per_month - .map(([month, data]) => ({ - name: month, - data, - })) - .sort((a, b) => a.name.localeCompare(b.name)) ?? [], - ); - - if (selectedYear) { - data = data.filter((obj) => obj.name.startsWith(selectedYear.toString())); - } else if (data.length > 36) { - data = mergeYears(data); - } - data = data.map((obj) => ({ - name: obj.name, - data: { - count: obj.data.count, - avg_elo: obj.data.avg_elo ? Math.round(obj.data.avg_elo) : null, - }, - })); - - const theme = useMantineTheme(); - const { colorScheme } = useMantineColorScheme(); - - return ( - - { - const year = Number.parseInt(e.activePayload?.[0]?.payload?.name); - if (year) { - setSelectedYear((prev) => (prev === year ? null : year)); - } - }} - > - - 3 - - - - - - - - - ); -} - -function ResultsChart({ - won, - draw, - lost, - size, -}: { - won: number; - draw: number; - lost: number; - size: string; -}) { - const total = won + draw + lost; - return ( - - - - - {won / total > 0.15 - ? `${((won / total) * 100).toFixed(1)}%` - : undefined} - - - - - - - - {draw / total > 0.15 - ? `${((draw / total) * 100).toFixed(1)}%` - : undefined} - - - - - - - - {lost / total > 0.15 - ? `${((lost / total) * 100).toFixed(1)}%` - : undefined} - - - - - ); -} - -function OpeningNameButton({ name, color }: { name: string; color: Color }) { - const navigate = useNavigate(); - - const [, setTabs] = useAtom(tabsAtom); - const [, setActiveTab] = useAtom(activeTabAtom); - return ( - { - const pgn = unwrap(await commands.getOpeningFromName(name)); - const headers = defaultTree().headers; - const tree = await parsePGN(pgn); - headers.orientation = color; - createTab({ - tab: { name, type: "analysis" }, - pgn, - headers, - setTabs, - setActiveTab, - position: Array(countMainPly(tree.root)).fill(0), - }); - navigate({ to: "/" }); - }} - > - {name} - - ); -} - export default PersonalPlayerCard; diff --git a/src/components/home/PersonalCardPanels/DateRangeTabs.tsx b/src/components/home/PersonalCardPanels/DateRangeTabs.tsx new file mode 100644 index 00000000..4766c030 --- /dev/null +++ b/src/components/home/PersonalCardPanels/DateRangeTabs.tsx @@ -0,0 +1,43 @@ +import { Tabs } from "@mantine/core"; + +export enum DateRange { + SevenDays = "7d", + ThirtyDays = "30d", + NinetyDays = "90d", + OneYear = "1y", + AllTime = "all", +} + +const DEFAULT_TIME_RANGES = [ + { value: DateRange.SevenDays, label: "7 days" }, + { value: DateRange.ThirtyDays, label: "30 days" }, + { value: DateRange.NinetyDays, label: "90 days" }, + { value: DateRange.OneYear, label: "1 year" }, + { value: DateRange.AllTime, label: "All time" }, +]; + +interface DateRangeTabsProps { + timeRange: string | null; + onTimeRangeChange: (value: string | null) => void; +} + +const DateRangeTabs = ({ timeRange, onTimeRangeChange }: DateRangeTabsProps) => { + return ( + + + {DEFAULT_TIME_RANGES.map((range) => ( + + {range.label} + + ))} + + + ); +}; + +export default DateRangeTabs; diff --git a/src/components/home/PersonalCard.css.ts b/src/components/home/PersonalCardPanels/OpeningsPanel.css.ts similarity index 100% rename from src/components/home/PersonalCard.css.ts rename to src/components/home/PersonalCardPanels/OpeningsPanel.css.ts diff --git a/src/components/home/PersonalCardPanels/OpeningsPanel.tsx b/src/components/home/PersonalCardPanels/OpeningsPanel.tsx new file mode 100644 index 00000000..d4be08c4 --- /dev/null +++ b/src/components/home/PersonalCardPanels/OpeningsPanel.tsx @@ -0,0 +1,222 @@ +import { useRef, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useAtom, useAtomValue } from "jotai"; +import { + Box, + Divider, + Group, + ScrollArea, + Stack, + Text, +} from "@mantine/core"; +import type { Color } from "chessops"; +import { activeTabAtom, fontSizeAtom, tabsAtom } from "@/state/atoms"; +import { parsePGN } from "@/utils/chess"; +import type { PlayerGameInfo } from "@/bindings"; +import { createTab } from "@/utils/tabs"; +import { countMainPly, defaultTree } from "@/utils/treeReducer"; +import { unwrap } from "@/utils/unwrap"; +import * as classes from "./OpeningsPanel.css"; +import ResultsChart from "./ResultsChart"; +import { commands, GameOutcome } from "@/bindings"; +import WebsiteAccountSelector from "./WebsiteAccountSelector"; +import TimeControlSelector from "./TimeControlSelector"; +import { getTimeControl } from "@/utils/timeControl"; + +type OpeningStats = { + name: string; + games: number; + won: number; + draw: number; + lost: number; +} + +function aggregateOpenings( + data: { opening: string; result: GameOutcome; is_player_white: boolean }[], + color: Color, +): OpeningStats[] { + return Array.from( + data + .filter((d) => d.is_player_white === (color === "white")) + .reduce((acc, d) => { + const prev = acc.get(d.opening) ?? { won: 0, draw: 0, lost: 0, total: 0 }; + acc.set(d.opening, { + won: prev.won + (d.result === 'Won' ? 1 : 0), + draw: prev.draw + (d.result === 'Drawn' ? 1 : 0), + lost: prev.lost + (d.result === 'Lost' ? 1 : 0), + total: prev.total + 1, + }); + return acc; + }, new Map()) + ) + .map(([name, { won, draw, lost, total }]) => ({ name, games: total, won, draw, lost })) + .sort((a, b) => b.games - a.games); +} + +function OpeningsPanel({ playerName, info }: { playerName: string; info: PlayerGameInfo }) { + const [website, setWebsite] = useState("All websites"); + const [account, setAccount] = useState("All accounts"); + const [timeControl, setTimeControl] = useState(null); + + const openingData = info?.site_stats_data + .filter((d) => website === "All websites" || d.site === website) + .filter((d) => account === "All accounts" || d.player === account) + .flatMap((d) => d.data) + .filter((g) => !timeControl + || timeControl === "any" + || getTimeControl(website!, g.time_control) === timeControl) + .map((g) => ({ + opening: g.opening, + result: g.result, + is_player_white: g.is_player_white, + })) ?? []; + + const whiteGames = openingData.filter((g) => g.is_player_white).length; + const blackGames = openingData.filter((g) => !g.is_player_white).length; + + const whiteOpenings = aggregateOpenings(openingData, "white"); + const blackOpenings = aggregateOpenings(openingData, "black"); + + const fontSize = useAtomValue(fontSizeAtom); + + const parentRef = useRef(null); + const rowVirtualizer = useVirtualizer({ + count: Math.max(whiteOpenings.length, blackOpenings.length), + estimateSize: () => 120 * (fontSize / 100), + getScrollElement: () => parentRef.current, + }); + + return ( + + { + setWebsite(website); + if (website === "All websites") { + setTimeControl(null); + } else if (timeControl === null) { + setTimeControl("any"); + } + }} + onAccountChange={setAccount} + allowAll={true} + /> + {website !== "All websites" && ( + + )} + + + White + + + Black + + + + + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const white = whiteOpenings[virtualRow.index]; + const black = blackOpenings[virtualRow.index]; + return ( + + + {white ? ( + + ) : ( +
+ )} + {black ? ( + + ) : ( +
+ )} + + + + ); + })} + + + + ); +} + +function OpeningDetail({ opening, totalGames, color }: { + opening: OpeningStats; + totalGames: number; + color: Color +}) { + const [, setTabs] = useAtom(tabsAtom); + const [, setActiveTab] = useAtom(activeTabAtom); + const navigate = useNavigate(); + + const openingRate = opening.games / totalGames; + return ( + + + { + const pgn = unwrap(await commands.getOpeningFromName(opening.name)); + const headers = defaultTree().headers; + const tree = await parsePGN(pgn); + headers.orientation = color; + createTab({ + tab: { name: opening.name, type: "analysis" }, + pgn, + headers, + setTabs, + setActiveTab, + position: Array(countMainPly(tree.root)).fill(0), + }); + navigate({ to: "/" }); + }} + > + {opening.name} + + + {(openingRate * 100).toFixed(2)}% + + + + + ); +} + +export default OpeningsPanel; diff --git a/src/components/home/PersonalCardPanels/OverviewPanel.tsx b/src/components/home/PersonalCardPanels/OverviewPanel.tsx new file mode 100644 index 00000000..69a29a4f --- /dev/null +++ b/src/components/home/PersonalCardPanels/OverviewPanel.tsx @@ -0,0 +1,203 @@ +import { useState } from "react"; +import { Stack, Text, useMantineColorScheme, useMantineTheme } from "@mantine/core"; +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, TooltipProps, XAxis, YAxis } from "recharts"; +import type { PlayerGameInfo } from "@/bindings"; +import { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"; +import ResultsChart from "./ResultsChart"; +import { StatsData } from "@/bindings"; +import WebsiteAccountSelector from "./WebsiteAccountSelector"; +import TimeControlSelector from "./TimeControlSelector"; +import { getTimeControl } from "@/utils/timeControl"; + +function fillMissingMonths( + data: { name: string; count: number }[], +): { name: string; count: number }[] { + if (data.length === 0) return data; + + data.sort((a, b) => a.name.localeCompare(b.name)); + + const monthStrings: string[] = []; + const startDate = new Date(`${data[0].name}-01`); + const endDate = new Date(`${data[data.length - 1].name}-01`); + + const timezoneOffset = new Date().getTimezoneOffset() * 60 * 1000; // milliseconds + const currDate = new Date(startDate); + while (currDate <= endDate) { + const localCurrDate = new Date(currDate.getTime() - timezoneOffset); + const monthString = localCurrDate.toISOString().slice(0, 7); + monthStrings.push(monthString); + currDate.setMonth(currDate.getMonth() + 1); + } + + const dataMap = new Map(data.map((item) => [item.name, item.count])); + const filledData = monthStrings.map((month) => ({ + name: month, + count: dataMap.get(month) || 0, + })); + + return filledData; +} + +function mergeYears( + data: { name: string; count: number }[], +): { name: string; count: number }[] { + const yearCounts: { [year: string]: number } = {}; + + data.forEach(({ name, count }) => { + const year = name.slice(0, 4); + yearCounts[year] = (yearCounts[year] || 0) + count; + }); + + return Object.entries(yearCounts) + .map(([year, count]) => ({ + name: year, + count, + })); +} + +function extractGameStats(games: StatsData[]) { + const total = games.length; + const won = games.filter((d) => d.result === "Won").length; + const draw = games.filter((d) => d.result === "Drawn").length; + const lost = games.filter((d) => d.result === "Lost").length; + + const monthCounts: { [key: string]: number } = {}; + games.forEach((game) => { + const monthString = game.date.slice(0, 7).replace(".", "-"); + monthCounts[monthString] = (monthCounts[monthString] || 0) + 1; + }); + + const dataPerMonth = Object.entries(monthCounts) + .map(([month, count]) => ({ + name: month, + count, + })) + + return { total, won, draw, lost, dataPerMonth }; +} + +function OverviewPanel({ playerName, info }: { playerName: string; info: PlayerGameInfo }) { + const [website, setWebsite] = useState("All websites"); + const [account, setAccount] = useState("All accounts"); + const [timeControl, setTimeControl] = useState(null); + + const games = info?.site_stats_data + .filter((d) => website === "All websites" || d.site === website) + .filter((d) => account === "All accounts" || d.player === account) + .flatMap((d) => d.data) + .filter((game) => !timeControl + || timeControl === "any" + || getTimeControl(website!, game.time_control) === timeControl) + ?? []; + const { total, won, draw, lost, dataPerMonth } = extractGameStats(games); + + return ( + + { + setWebsite(website); + if (website === "All websites") { + setTimeControl(null); + } else if (timeControl === null) { + setTimeControl("any"); + } + }} + onAccountChange={setAccount} + allowAll={true} + /> + {website !== "All websites" && ( + + )} + + + {total} Games + + + {total > 0 && ( + <> + + + + )} + + ); +} + +const DateChartTooltip = ({ + active, + payload, + label, + isYearSelected, +}: TooltipProps & { isYearSelected: boolean }) => { + if (active && payload && payload.length) { + return ( +
+

{`${label}`}

+

{`${payload?.[0].name} : ${payload?.[0].value}`}

+

+ Click to {isYearSelected ? "see the month details" : "return to the years view"}. +

+
+ ); + } + + return null; +}; + +function DateChart({ dataPerMonth }: { dataPerMonth: { name: string; count: number }[] }) { + const [selectedYear, setSelectedYear] = useState(null); + + let data = fillMissingMonths(dataPerMonth); + + if (selectedYear) { + data = data.filter((obj) => obj.name.startsWith(selectedYear.toString())); + } else if (data.length > 36) { + data = mergeYears(data); + } + + return ( + + { + const year = Number.parseInt(e.activePayload?.[0]?.payload?.name); + if (year) { + setSelectedYear((prev) => (prev === year ? null : year)); + } + }} + > + + + + } + cursor={{ + fill: "var(--mantine-color-default-border)", + stroke: "1px solid var(--chart-grid-color)", + }} + /> + + + + ); +} + +export default OverviewPanel; diff --git a/src/components/home/PersonalCardPanels/RatingsPanel.css.ts b/src/components/home/PersonalCardPanels/RatingsPanel.css.ts new file mode 100644 index 00000000..2af773cb --- /dev/null +++ b/src/components/home/PersonalCardPanels/RatingsPanel.css.ts @@ -0,0 +1,25 @@ +export const tooltipContentStyle = { + backgroundColor: "var(--mantine-color-body)", + boxShadow: "var(--mantine-shadow-md)", + borderRadius: "var(--mantine-radius-default)", + border: "calc(0.0625rem * var(--mantine-scale)) solid var(--mantine-color-default-border)", +}; + +export const tooltipCursorStyle = { + stroke: "rgba(105, 105, 105, 0.6)", + strokeWidth: 1, + strokeDasharray: "5 5", +}; + +export const linearGradientProps = { + id: "colorRating", + x1: "0", + y1: "0", + x2: "0", + y2: "1", +}; + +export const gradientStops = [ + { offset: "5%", stopColor: "#1971c2", stopOpacity: 1 }, + { offset: "95%", stopColor: "#1971c2", stopOpacity: 0.1 }, +]; diff --git a/src/components/home/PersonalCardPanels/RatingsPanel.tsx b/src/components/home/PersonalCardPanels/RatingsPanel.tsx new file mode 100644 index 00000000..44b938c0 --- /dev/null +++ b/src/components/home/PersonalCardPanels/RatingsPanel.tsx @@ -0,0 +1,210 @@ +import { useState, useEffect, useMemo } from "react"; +import { + Stack, + Text, +} from "@mantine/core"; +import { + AreaChart, + Area, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { PlayerGameInfo } from "@/bindings"; +import ResultsChart from "./ResultsChart"; +import WebsiteAccountSelector from "./WebsiteAccountSelector"; +import TimeControlSelector from "./TimeControlSelector"; +import TimeRangeSlider from "./TimeRangeSlider"; +import DateRangeTabs from "./DateRangeTabs"; +import { getTimeControl } from "@/utils/timeControl"; +import { DateRange as DateRange } from "./DateRangeTabs"; +import { + tooltipContentStyle, + tooltipCursorStyle, + linearGradientProps, + gradientStops, +} from './RatingsPanel.css'; + +function calculateEarliestDate(dateRange: DateRange, ratingDates: number[]): number { + const lastDate = ratingDates[ratingDates.length - 1]; + switch (dateRange) { + case DateRange.SevenDays: + return lastDate - 7 * 24 * 60 * 60 * 1000; + case DateRange.ThirtyDays: + return lastDate - 30 * 24 * 60 * 60 * 1000; + case DateRange.NinetyDays: + return lastDate - 90 * 24 * 60 * 60 * 1000; + case DateRange.OneYear: + return lastDate - 365 * 24 * 60 * 60 * 1000; + case DateRange.AllTime: + default: + return Math.min(...ratingDates); + } +} + +function RatingsPanel({ playerName, info }: { playerName: string; info: PlayerGameInfo }) { + const [dateRange, setDateRange] = useState(DateRange.NinetyDays); + const [timeControl, setTimeControl] = useState(null); + const [website, setWebsite] = useState(null); + const [account, setAccount] = useState(null); + const [timeRange, setTimeRange] = useState({ start: 0, end: 0 }); + + const dates = useMemo(() => { + const timezoneOffset = new Date().getTimezoneOffset() * 60 * 1000; // milliseconds + const localDate = new Date(Date.now() - timezoneOffset); + const todayString = localDate.toISOString().slice(0, 10); + const today = new Date(todayString).getTime(); + return Array.from( + new Set([today, ...( + info.site_stats_data + ?.filter((games) => games.site === website) + .filter((games) => account === "All accounts" || games.player === account) + .flatMap((games) => games.data) + .filter((game) => getTimeControl(website!, game.time_control) === timeControl) + .map((game) => new Date(game.date).getTime()) + )]) + ).sort((a, b) => a - b); + }, [info.site_stats_data, website, account, timeControl]); + + useEffect(() => { + if (dateRange) { + const earliestDate = calculateEarliestDate(dateRange as DateRange, dates); + const earliestIndex = dates.findIndex((date) => date >= earliestDate); + setTimeRange({ start: earliestIndex, end: dates.length - 1 }); + } + else { + setTimeRange({ start: 0, end: dates.length > 0 ? dates.length - 1 : 0 }); + } + }, [dateRange, dates]); + + const [summary, ratingData] = useMemo(() => { + const filteredGames = info.site_stats_data + ?.filter((games) => games.site === website) + .filter((games) => account === "All accounts" || games.player === account) + .flatMap((games) => games.data) + .filter((game) => getTimeControl(website!, game.time_control) === timeControl) + .filter((game) => { + const gameDate = new Date(game.date).getTime(); + return gameDate >= (dates[timeRange.start] || 0) + && gameDate <= (dates[timeRange.end] || 0); + }) ?? []; + + const totalGamesCount = filteredGames.length; + const wonCount = filteredGames.filter((game) => game.result === "Won").length; + const drawCount = filteredGames.filter((game) => game.result === "Drawn").length; + const lostCount = filteredGames.filter((game) => game.result === "Lost").length; + + const ratingData = (() => { + const map = new Map(); + filteredGames.forEach((game) => { + const date = new Date(game.date).getTime(); + if (!map.has(date) || map.get(date)!.player_elo < game.player_elo) { + map.set(date, { date, player_elo: game.player_elo }); + } + }); + return Array.from(map.values()).sort((a, b) => a.date - b.date); + })(); + + return [ + { + games: totalGamesCount, + won: wonCount, + draw: drawCount, + lost: lostCount + }, + ratingData + ]; + }, [info.site_stats_data, website, account, timeControl, timeRange]); + + const playerEloDomain = ratingData.length == 0 ? + null : + ratingData.reduce( + ([min, max], { player_elo }) => [ + Math.floor(Math.min(min, player_elo) / 50) * 50, + Math.ceil(Math.max(max, player_elo) / 50) * 50 + ], + [Infinity, -Infinity] + ); + + return ( + + + + + + {summary.games} Games + + {dates.length > 1 && ( + <> + {summary.games > 0 && ( + + )} + + + + + {gradientStops.map((stopProps, index) => ( + + ))} + + + + new Date(date).toLocaleDateString()} + type="number" + /> + {playerEloDomain == null && ()} + {playerEloDomain != null && ()} + new Date(label).toLocaleDateString()} + /> + + + + { + setDateRange(null); + setTimeRange(range); + }} + /> + + )} + + ); +} + +export default RatingsPanel; diff --git a/src/components/home/PersonalCardPanels/ResultsChart.tsx b/src/components/home/PersonalCardPanels/ResultsChart.tsx new file mode 100644 index 00000000..933921b8 --- /dev/null +++ b/src/components/home/PersonalCardPanels/ResultsChart.tsx @@ -0,0 +1,50 @@ +import { Progress, Tooltip as MTTooltip } from "@mantine/core"; + +function ResultsChart({ + won, + draw, + lost, + size, +}: { + won: number; + draw: number; + lost: number; + size: string; +}) { + const total = won + draw + lost; + return ( + + + + + {won / total > 0.15 + ? `${((won / total) * 100).toFixed(1)}%` + : undefined} + + + + + + + + {draw / total > 0.15 + ? `${((draw / total) * 100).toFixed(1)}%` + : undefined} + + + + + + + + {lost / total > 0.15 + ? `${((lost / total) * 100).toFixed(1)}%` + : undefined} + + + + + ); +} + +export default ResultsChart; diff --git a/src/components/home/PersonalCardPanels/TimeControlSelector.tsx b/src/components/home/PersonalCardPanels/TimeControlSelector.tsx new file mode 100644 index 00000000..765645ad --- /dev/null +++ b/src/components/home/PersonalCardPanels/TimeControlSelector.tsx @@ -0,0 +1,61 @@ +import { Select } from "@mantine/core"; +import { useEffect, useState } from "react"; + +const LICHESS_TIME_CONTROLS = [ + { value: "ultra_bullet", label: "UltraBullet" }, + { value: "bullet", label: "Bullet" }, + { value: "blitz", label: "Blitz" }, + { value: "rapid", label: "Rapid" }, + { value: "classical", label: "Classical" }, + { value: "correspondence", label: "Correspondence" }, +]; + +const CHESSCOM_TIME_CONTROLS = [ + { value: "bullet", label: "Bullet" }, + { value: "blitz", label: "Blitz" }, + { value: "rapid", label: "Rapid" }, + { value: "daily", label: "Daily" }, +]; + +interface TimeControlSelectorProps { + onTimeControlChange: (value: string | null) => void; + website: string | null; + allowAll: boolean; +} + +const TimeControlSelector = ({ + onTimeControlChange, + website, + allowAll, +}: TimeControlSelectorProps) => { + const timeControls = (website === "Chess.com") + ? [...(allowAll ? [{ value: "any", label: "Any" }] : []), ...CHESSCOM_TIME_CONTROLS] + : [...(allowAll ? [{ value: "any", label: "Any" }] : []), ...LICHESS_TIME_CONTROLS]; + + const defaultTimeControl = allowAll ? "any" : "rapid"; + const [timeControl, setTimeControl] = useState(defaultTimeControl); + + + useEffect(() => { + onTimeControlChange(timeControl); + }, [timeControl]); + + useEffect(() => { + if (!timeControls.some((control) => control.value === timeControl)) { + setTimeControl(defaultTimeControl); + } + }, [website, timeControls]); + + return ( + { + setWebsite(value); + setAccount("All accounts"); + }} + data={websites} + allowDeselect={false} + /> + {website !== "All websites" && ( +