From 9640e59194012fed88e778cd48010673ccabf7a4 Mon Sep 17 00:00:00 2001 From: AbuJulaybeeb Date: Tue, 28 Apr 2026 01:15:08 +0100 Subject: [PATCH 1/2] feat: implement database indexing and premium UI for historical game search --- backend/modules/api/src/games.rs | 8 + backend/modules/db/migrations/src/lib.rs | 2 + ...28_100000_add_historical_search_indexes.rs | 72 ++++++ backend/modules/dto/src/games.rs | 15 ++ backend/modules/service/src/games.rs | 80 ++++++- .../components/dashboard/RecentMatches.tsx | 216 +++++++++++++----- 6 files changed, 322 insertions(+), 71 deletions(-) create mode 100644 backend/modules/db/migrations/src/m20260428_100000_add_historical_search_indexes.rs diff --git a/backend/modules/api/src/games.rs b/backend/modules/api/src/games.rs index 83b4a16f..29b26792 100644 --- a/backend/modules/api/src/games.rs +++ b/backend/modules/api/src/games.rs @@ -208,11 +208,19 @@ pub async fn list_games( let limit = query.limit.unwrap_or(10); let cursor = query.cursor.clone(); + // Map result_side string to enum if needed, or pass as string for service to handle. + // For simplicity, we pass Option filters to service. + match GameService::list_games( db.get_ref(), cursor, limit, query.player_id, + query.opponent_id, + query.variant.clone(), + query.result_side.clone(), + query.from_date, + query.to_date, status_enum, ) .await diff --git a/backend/modules/db/migrations/src/lib.rs b/backend/modules/db/migrations/src/lib.rs index 246b265f..b4a2a15f 100644 --- a/backend/modules/db/migrations/src/lib.rs +++ b/backend/modules/db/migrations/src/lib.rs @@ -9,6 +9,7 @@ mod m20250605_090000_add_game_search_indexes; mod m20260127_create_refresh_tokens_table; mod m20260127_180000_add_game_imported_flag; mod m20250324_add_elo_rating_to_player; +mod m20260428_100000_add_historical_search_indexes; pub struct Migrator; @@ -26,6 +27,7 @@ impl MigratorTrait for Migrator { Box::new(m20260127_create_refresh_tokens_table::Migration), Box::new(m20260127_180000_add_game_imported_flag::Migration), Box::new(m20250324_add_elo_rating_to_player::Migration), + Box::new(m20260428_100000_add_historical_search_indexes::Migration), ] } } diff --git a/backend/modules/db/migrations/src/m20260428_100000_add_historical_search_indexes.rs b/backend/modules/db/migrations/src/m20260428_100000_add_historical_search_indexes.rs new file mode 100644 index 00000000..d204f08e --- /dev/null +++ b/backend/modules/db/migrations/src/m20260428_100000_add_historical_search_indexes.rs @@ -0,0 +1,72 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 1. Index on `result` column for fast filtering of game outcomes + manager + .create_index( + Index::create() + .name("idx_games_result") + .table((Smdb, Game::Table)) + .col(Game::Result) + .to_owned(), + ) + .await?; + + // 2. Composite index for head-to-head history: (white_player, black_player, created_at DESC) + // This is extremely useful for searching games between two specific players. + manager + .get_connection() + .execute_unprepared( + r#"CREATE INDEX "idx_games_head_to_head" ON "smdb"."game" ("white_player", "black_player", "created_at" DESC)"# + ) + .await?; + + // 3. Index on `variant` for variant-specific historical search + manager + .create_index( + Index::create() + .name("idx_games_variant_search") + .table((Smdb, Game::Table)) + .col(Game::Variant) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index(Index::drop().name("idx_games_result").table((Smdb, Game::Table)).to_owned()) + .await?; + + manager + .get_connection() + .execute_unprepared(r#"DROP INDEX IF EXISTS "idx_games_head_to_head""#) + .await?; + + manager + .drop_index(Index::drop().name("idx_games_variant_search").table((Smdb, Game::Table)).to_owned()) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Game { + Table, + Result, + WhitePlayer, + BlackPlayer, + Variant, + CreatedAt, +} + +#[derive(DeriveIden)] +struct Smdb; diff --git a/backend/modules/dto/src/games.rs b/backend/modules/dto/src/games.rs index c5aa362d..7d5fd605 100644 --- a/backend/modules/dto/src/games.rs +++ b/backend/modules/dto/src/games.rs @@ -123,6 +123,21 @@ pub struct ListGamesQuery { #[schema(value_type = Option, format = "uuid", example = "123e4567-e89b-12d3-a456-426614174000")] pub player_id: Option, + + #[schema(value_type = Option, format = "uuid", example = "123e4567-e89b-12d3-a456-426614174001")] + pub opponent_id: Option, + + #[schema(example = "standard")] + pub variant: Option, + + #[schema(example = "white_wins")] + pub result_side: Option, + + #[schema(value_type = Option, format = "date-time")] + pub from_date: Option>, + + #[schema(value_type = Option, format = "date-time")] + pub to_date: Option>, #[schema(default = 1, example = 1)] /// Deprecated: Use cursor-based pagination diff --git a/backend/modules/service/src/games.rs b/backend/modules/service/src/games.rs index 97431a84..63f204f0 100644 --- a/backend/modules/service/src/games.rs +++ b/backend/modules/service/src/games.rs @@ -170,22 +170,76 @@ impl GameService { cursor: Option, limit: u64, player_id: Option, + opponent_id: Option, + variant: Option, + result_side: Option, + from_date: Option>, + to_date: Option>, status: Option, ) -> Result<(Vec, Option), DbErr> { let mut query = Game::find(); // 1. Apply Filtering if let Some(pid) = player_id { - // Filter by player (white OR black) - // effective union of indexes logic would be nice, but OR is simpler to write here. - // "idx_games_white_player_created_at_id" and "idx_games_black_player_created_at_id" - // Postgres creates a BitmapOr for these two indexes usually. + if let Some(oid) = opponent_id { + // Filter by head-to-head (me vs opponent OR opponent vs me) + let condition = Condition::any() + .add( + Condition::all() + .add(game::Column::WhitePlayer.eq(pid)) + .add(game::Column::BlackPlayer.eq(oid)) + ) + .add( + Condition::all() + .add(game::Column::WhitePlayer.eq(oid)) + .add(game::Column::BlackPlayer.eq(pid)) + ); + query = query.filter(condition); + } else { + // Filter by player (white OR black) + let condition = Condition::any() + .add(game::Column::WhitePlayer.eq(pid)) + .add(game::Column::BlackPlayer.eq(pid)); + query = query.filter(condition); + } + } else if let Some(oid) = opponent_id { + // Filter by opponent only (either white or black) let condition = Condition::any() - .add(game::Column::WhitePlayer.eq(pid)) - .add(game::Column::BlackPlayer.eq(pid)); + .add(game::Column::WhitePlayer.eq(oid)) + .add(game::Column::BlackPlayer.eq(oid)); query = query.filter(condition); } + if let Some(v) = variant { + // Map variant string to enum + let v_enum = match v.as_str() { + "chess960" => db_entity::game::GameVariant::Chess960, + "three-check" => db_entity::game::GameVariant::ThreeCheck, + _ => db_entity::game::GameVariant::Standard, + }; + query = query.filter(game::Column::Variant.eq(v_enum)); + } + + if let Some(r) = result_side { + // Map result string to enum + let r_enum = match r.as_str() { + "white_wins" => db_entity::game::ResultSide::WhiteWins, + "black_wins" => db_entity::game::ResultSide::BlackWins, + "draw" => db_entity::game::ResultSide::Draw, + "abandoned" => db_entity::game::ResultSide::Abandoned, + _ => db_entity::game::ResultSide::Ongoing, + }; + query = query.filter(game::Column::Result.eq(r_enum)); + } + + if let Some(from) = from_date { + query = query.filter(game::Column::CreatedAt.gte(from)); + } + + if let Some(to) = to_date { + query = query.filter(game::Column::CreatedAt.lte(to)); + } + if let Some(s) = status { match s { GameStatus::Waiting | GameStatus::InProgress => { @@ -345,7 +399,12 @@ mod tests { None, 10, Some(player_id), - None + None, + None, + None, + None, + None, + None, ).await; // Get transaction log to verify SQL @@ -397,7 +456,12 @@ mod tests { Some(cursor), 10, None, - None + None, + None, + None, + None, + None, + None, ).await; let transaction_log = db.into_transaction_log(); diff --git a/frontend/components/dashboard/RecentMatches.tsx b/frontend/components/dashboard/RecentMatches.tsx index fd46ff2f..8f8ae871 100644 --- a/frontend/components/dashboard/RecentMatches.tsx +++ b/frontend/components/dashboard/RecentMatches.tsx @@ -1,29 +1,20 @@ -"use client"; - +import { useState, useMemo } from "react"; import type { EloDataPoint } from "@/components/profile/EloRatingChart"; import { cn } from "@/lib/utils"; +import { Search, Filter, X, ChevronDown, SearchX } from "lucide-react"; interface RecentMatchesProps { data: EloDataPoint[]; } function getResult(change: number): "W" | "L" | "D" { - if (change > 0) { - return "W"; - } - - if (change < 0) { - return "L"; - } - + if (change > 0) return "W"; + if (change < 0) return "L"; return "D"; } function formatOpponent(opponent: string): string { - if (opponent.length <= 12) { - return opponent; - } - + if (opponent.length <= 12) return opponent; return `${opponent.slice(0, 6)}...${opponent.slice(-4)}`; } @@ -34,66 +25,165 @@ const badgeClassMap = { } as const; export default function RecentMatches({ data }: RecentMatchesProps) { - const matches = [...data].slice(-10).reverse(); + const [searchQuery, setSearchQuery] = useState(""); + const [showFilters, setShowFilters] = useState(false); + const [resultFilter, setResultFilter] = useState<"ALL" | "W" | "L" | "D">("ALL"); + + const filteredMatches = useMemo(() => { + return [...data] + .reverse() + .filter((match) => { + const matchesSearch = match.opponent.toLowerCase().includes(searchQuery.toLowerCase()); + const result = getResult(match.change); + const matchesResult = resultFilter === "ALL" || result === resultFilter; + return matchesSearch && matchesResult; + }) + .slice(0, 10); + }, [data, searchQuery, resultFilter]); return (
-
+
+ +
-

Recent matches

-

Last 10 rating events in the selected range.

+

Historical Matches

+

Search and analyze your past performance

+
+ +
+
+ + setSearchQuery(e.target.value)} + className="w-full rounded-xl border border-gray-700/50 bg-gray-950/50 py-2 pl-10 pr-4 text-sm text-white outline-none transition-all focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/20 sm:w-64" + /> + {searchQuery && ( + + )} +
+ +
- - {matches.length} shown -
-
- - - - - - - - + {showFilters && ( +
+ {["ALL", "W", "L", "D"].map((r) => ( + + ))} +
+ )} + +
+
DateOpponentResultΔ ELONew ELO
+ + + + + + - - {matches.map((match, index) => { - const result = getResult(match.change); - - return ( - - - - - - - - ); - })} + + {filteredMatches.length > 0 ? ( + filteredMatches.map((match, index) => { + const result = getResult(match.change); + return ( + + + + + + + ); + }) + ) : ( + + + + )}
TimelineAdversaryOutcomeRating Impact
- {new Date(match.date).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - })} - {formatOpponent(match.opponent)} - - {result} - - = 0 ? "text-emerald-400" : "text-red-400")}> - {match.change >= 0 ? "+" : ""}{match.change} - {match.elo}
+ {new Date(match.date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + +
+
+ {formatOpponent(match.opponent)} +
+
+ + {result === "W" ? "VICTORY" : result === "L" ? "DEFEAT" : "DRAW"} + + +
+ = 0 ? "text-emerald-400" : "text-red-400" + )}> + {match.change >= 0 ? "+" : ""}{match.change} + +
+ {match.elo} +
+
+
+
+ +
+
+

No matches found

+

Try adjusting your filters or search query

+
+ +
+
From 2c557d0d37a537f20ccad4da344d5cd0192ff1a1 Mon Sep 17 00:00:00 2001 From: AbuJulaybeeb Date: Thu, 30 Apr 2026 01:59:08 +0100 Subject: [PATCH 2/2] fix: optimize game search indexes, correct variant mapping, and enhance component accessibility --- ...28_100000_add_historical_search_indexes.rs | 16 ++++- backend/modules/service/src/games.rs | 60 ++++++++----------- .../components/dashboard/RecentMatches.tsx | 5 ++ 3 files changed, 46 insertions(+), 35 deletions(-) diff --git a/backend/modules/db/migrations/src/m20260428_100000_add_historical_search_indexes.rs b/backend/modules/db/migrations/src/m20260428_100000_add_historical_search_indexes.rs index d204f08e..ae26386a 100644 --- a/backend/modules/db/migrations/src/m20260428_100000_add_historical_search_indexes.rs +++ b/backend/modules/db/migrations/src/m20260428_100000_add_historical_search_indexes.rs @@ -26,6 +26,15 @@ impl MigrationTrait for Migration { ) .await?; + // 2b. Mirrored composite index for head-to-head history: (black_player, white_player, created_at DESC) + // This ensures queries in both color directions are fully optimized. + manager + .get_connection() + .execute_unprepared( + r#"CREATE INDEX "idx_games_head_to_head_mirrored" ON "smdb"."game" ("black_player", "white_player", "created_at" DESC)"# + ) + .await?; + // 3. Index on `variant` for variant-specific historical search manager .create_index( @@ -47,7 +56,12 @@ impl MigrationTrait for Migration { manager .get_connection() - .execute_unprepared(r#"DROP INDEX IF EXISTS "idx_games_head_to_head""#) + .execute_unprepared(r#"DROP INDEX IF EXISTS "smdb"."idx_games_head_to_head""#) + .await?; + + manager + .get_connection() + .execute_unprepared(r#"DROP INDEX IF EXISTS "smdb"."idx_games_head_to_head_mirrored""#) .await?; manager diff --git a/backend/modules/service/src/games.rs b/backend/modules/service/src/games.rs index 63f204f0..7825323f 100644 --- a/backend/modules/service/src/games.rs +++ b/backend/modules/service/src/games.rs @@ -212,24 +212,38 @@ impl GameService { if let Some(v) = variant { // Map variant string to enum - let v_enum = match v.as_str() { + let v_enum = match v.to_lowercase().as_str() { + "standard" => db_entity::game::GameVariant::Standard, "chess960" => db_entity::game::GameVariant::Chess960, - "three-check" => db_entity::game::GameVariant::ThreeCheck, - _ => db_entity::game::GameVariant::Standard, + "three_check" => db_entity::game::GameVariant::ThreeCheck, + "blitz" => db_entity::game::GameVariant::Blitz, + "rapid" => db_entity::game::GameVariant::Rapid, + "classical" => db_entity::game::GameVariant::Classical, + _ => return Err(DbErr::Custom(format!("Invalid game variant: {}", v))), }; query = query.filter(game::Column::Variant.eq(v_enum)); } if let Some(r) = result_side { - // Map result string to enum - let r_enum = match r.as_str() { - "white_wins" => db_entity::game::ResultSide::WhiteWins, - "black_wins" => db_entity::game::ResultSide::BlackWins, - "draw" => db_entity::game::ResultSide::Draw, - "abandoned" => db_entity::game::ResultSide::Abandoned, - _ => db_entity::game::ResultSide::Ongoing, - }; - query = query.filter(game::Column::Result.eq(r_enum)); + // Map result string to enum or filter by NULL for ongoing + match r.to_lowercase().as_str() { + "white_wins" => { + query = query.filter(game::Column::Result.eq(db_entity::game::ResultSide::WhiteWins)); + } + "black_wins" => { + query = query.filter(game::Column::Result.eq(db_entity::game::ResultSide::BlackWins)); + } + "draw" => { + query = query.filter(game::Column::Result.eq(db_entity::game::ResultSide::Draw)); + } + "abandoned" => { + query = query.filter(game::Column::Result.eq(db_entity::game::ResultSide::Abandoned)); + } + "ongoing" => { + query = query.filter(game::Column::Result.is_null()); + } + _ => return Err(DbErr::Custom(format!("Invalid result side: {}", r))), + } } if let Some(from) = from_date { @@ -263,28 +277,6 @@ impl GameService { if let Some(cursor_str) = cursor { if let Ok((last_created_at, last_id)) = Self::decode_cursor(&cursor_str) { - // created_at < last_created_at OR (created_at = last_created_at AND id < last_id) - // SeaORM tuple comparison: (col1, col2) < (val1, val2) - // query = query.filter( - // Condition::any() - // .add(game::Column::CreatedAt.lt(last_created_at)) - // .add( - // Condition::all() - // .add(game::Column::CreatedAt.eq(last_created_at)) - // .add(game::Column::Id.lt(last_id)) - // ) - // ); - // Actually, SeaORM supports tuple comparison conveniently? - // Not directly in the builder API widely in all versions, but the composite condition above is correct for (A, B) < (a, b) logic. - // However, tuple comparison `(A, B) < (a, b)` logic is standard SQL but SeaORM DSL is explicit. - - // Constructing: (created_at, id) < (last_created_at, last_id) - // Equivalent to: created_at < last_created_at OR (created_at = last_created_at AND id < last_id) (for DESC, DESC) - // WAIT! For DESC sort, "next page" means values SMALLER than cursor? - // Yes. Sorting DESC means newest first. Cursor is at some point. We want older stuff. - // So we want `created_at < cursor.created_at`. - // If created_at == cursor.created_at, then `id < cursor.id` (assuming ID also DESC). - let condition = Condition::any() .add(game::Column::CreatedAt.lt(last_created_at)) .add( diff --git a/frontend/components/dashboard/RecentMatches.tsx b/frontend/components/dashboard/RecentMatches.tsx index 8f8ae871..03eb590d 100644 --- a/frontend/components/dashboard/RecentMatches.tsx +++ b/frontend/components/dashboard/RecentMatches.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useState, useMemo } from "react"; import type { EloDataPoint } from "@/components/profile/EloRatingChart"; import { cn } from "@/lib/utils"; @@ -61,6 +63,7 @@ export default function RecentMatches({ data }: RecentMatchesProps) { setSearchQuery(e.target.value)} className="w-full rounded-xl border border-gray-700/50 bg-gray-950/50 py-2 pl-10 pr-4 text-sm text-white outline-none transition-all focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/20 sm:w-64" @@ -77,6 +80,8 @@ export default function RecentMatches({ data }: RecentMatchesProps) {