diff --git a/CHANGELOG.md b/CHANGELOG.md index 6edc17f..99d30a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Game lists show the full date and the round** — the Players and Games pages + showed only the year. They now show the date (as much of it as is known) and + a Round column, and games played on the same day are listed in round order, + newest first. Games whose exact date is unknown come after the dated games of + their year, not before them. (#292) +- **Engine evals read the same way for both sources** — chessdb.cn scored moves + for the side to move, so with Black to move a good move showed as positive, + while Lichess showed it as negative. Both now show the eval from White's side: + positive means White is better, negative means Black is better. (#292) +- **Lichess evals are colour-coded too** — only chessdb evals were. In both + lists green now means the move is good for the player to move and red means + it's bad, so with Black to move a good -0.40 is green. (#292) + +### Fixed +- **The scrollbar no longer covers the close ✕ on the Analysis page** — in the + column of open games, the scrollbar sat on top of each game's close button. + The column now leaves room for it. (#292) + ## [0.17.0] - 2026-09-14 ### Fixed diff --git a/chess-client/src/components/AnalysisPage.tsx b/chess-client/src/components/AnalysisPage.tsx index 04fafcc..549a832 100644 --- a/chess-client/src/components/AnalysisPage.tsx +++ b/chess-client/src/components/AnalysisPage.tsx @@ -159,7 +159,9 @@ export default function AnalysisPage({ tabs, activeKey, onActivate, onClose, onO minSize={rz.floor("rail") ?? "5"} maxSize="16" > -
+ {/* Right padding keeps the cards clear of the scrollbar, which WebKitGTK + draws over the content instead of beside it — it hid the close ✕. */} +
{tabs.map((t) => { const on = t.key === activeKey; return ( diff --git a/chess-client/src/components/CloudEngine.tsx b/chess-client/src/components/CloudEngine.tsx index 9b4413b..275b47e 100644 --- a/chess-client/src/components/CloudEngine.tsx +++ b/chess-client/src/components/CloudEngine.tsx @@ -123,15 +123,20 @@ function moveMark(best: number, score: number): string { return drop <= STRONG_MARK_CP ? "!" : drop <= STRONG_CP ? "" : "?"; } -/** Score from the side-to-move's perspective, e.g. "+0.30", "-1.15", "M3". */ -function fmtEval(m: CloudMove): string { - if (m.mate !== null) return m.mate > 0 ? `M${m.mate}` : `-M${-m.mate}`; - const p = m.scoreCp / 100; - return (p > 0 ? "+" : "") + p.toFixed(2); +/** Colour for a side-to-move score: green = good for the player to move, red = + * bad. Deliberately not the displayed (White-relative) sign, so with Black to + * move a good -0.40 is green. */ +function evalColor(moverCp: number): string { + return moverCp > 0 ? "text-success" : moverCp < 0 ? "text-error" : "text-on-surface-variant"; } -function evalColor(m: CloudMove): string { - const v = m.mate !== null ? m.mate : m.scoreCp; - return v > 0 ? "text-success" : v < 0 ? "text-error" : "text-on-surface-variant"; + +/** chessdb scores are side-to-move relative; show them White-relative like + * Lichess (positive = White better), e.g. "+0.30", "-1.15", "M3" / "-M3". */ +function fmtEval(m: CloudMove, whiteToMove: boolean): string { + const s = whiteToMove ? 1 : -1; + if (m.mate !== null) { const mt = s * m.mate; return mt > 0 ? `M${mt}` : `-M${-mt}`; } + const p = (s * m.scoreCp) / 100; + return (p > 0 ? "+" : "") + p.toFixed(2); } interface Props { @@ -386,7 +391,7 @@ export default function CloudEngine({ fen, watchLabel, onPlayLine }: Props) {
{nn ? Number(nn.opp) : "—"} {nn ? Number(nn.oppStrong) : "—"} - {fmtEval(m)} + {fmtEval(m, fen.split(" ")[1] !== "b")}
); })} @@ -463,7 +468,7 @@ export default function CloudEngine({ fen, watchLabel, onPlayLine }: Props) { {lichessShowStats && {st ? st.replies : "—"}} {lichessShowStats && {st ? st.strong : "—"}} - {fmtLichess(l)} + {fmtLichess(l)} ); })} diff --git a/chess-client/src/components/GamesPage.tsx b/chess-client/src/components/GamesPage.tsx index 24b3913..02fb699 100644 --- a/chess-client/src/components/GamesPage.tsx +++ b/chess-client/src/components/GamesPage.tsx @@ -73,6 +73,17 @@ interface Props { leadingPanelSize?: number; } +// Dates are stored PGN-style with unknown parts as "??" ("2026-03-??"): show only +// the known part. +function displayDate(date: string | null): string { + return date?.replace(/-?\?\?.*$/, "") ?? ""; +} + +// "?" and "-" are PGN for an unknown round. +function displayRound(round: string | null | undefined): string { + return round && round !== "?" && round !== "-" ? round : ""; +} + export default function GamesPage({ scopePublicOnly, scopeCollectionId, scopeIncludeDeleted, player, onOpenInAnalysis, collections, onCollectionChange, reloadKey, leadingPanel, leadingPanelSize = 14 }: Props) { const playerScoped = player !== undefined; // Restore the Games page's last analysed line + filters (once, on mount). Never @@ -320,17 +331,20 @@ export default function GamesPage({ scopePublicOnly, scopeCollectionId, scopeInc const vHandle = "w-1.5 bg-transparent hover:bg-primary/30 data-[resize-handle-state=drag]:bg-primary/50 transition-colors"; const hHandle = "h-1.5 bg-transparent hover:bg-primary/30 data-[resize-handle-state=drag]:bg-primary/50 transition-colors"; - // Resizable game-list columns. White/Black/Result/Year have user-draggable + // Resizable game-list columns. White/Black/Result/Date/Round have user-draggable // widths (persisted); Event fills the remainder so no space is wasted. - type ColKey = "white" | "black" | "result" | "year"; - const COL_ORDER: ColKey[] = ["white", "black", "result", "year"]; + type ColKey = "white" | "black" | "result" | "date" | "round"; + const COL_ORDER: ColKey[] = ["white", "black", "result", "date", "round"]; + const COL_LABEL: Record = { white: "White", black: "Black", result: "Result", date: "Date", round: "Round" }; const COL_MIN = 36; const [colW, setColW] = useState>(() => { - try { const s = localStorage.getItem("gamesColWidths"); if (s) return JSON.parse(s); } catch { /* ignore */ } - return { white: 170, black: 170, result: 48, year: 52 }; + const defaults = { white: 170, black: 170, result: 48, date: 88, round: 52 }; + // Merge over the defaults: widths saved before a column existed lack its key. + try { const s = localStorage.getItem("gamesColWidths"); if (s) return { ...defaults, ...JSON.parse(s) }; } catch { /* ignore */ } + return defaults; }); useEffect(() => { try { localStorage.setItem("gamesColWidths", JSON.stringify(colW)); } catch { /* ignore */ } }, [colW]); - const gridCols = `${colW.white}px ${colW.black}px ${colW.result}px ${colW.year}px minmax(0,1fr)`; + const gridCols = `${COL_ORDER.map((k) => `${colW[k]}px`).join(" ")} minmax(0,1fr)`; // A divider belongs to the two columns it separates: dragging it trades width // between that pair and leaves every other boundary where it is — the same // rule the panel dividers follow. (Growing one column and letting the trailing @@ -589,9 +603,9 @@ export default function GamesPage({ scopePublicOnly, scopeCollectionId, scopeInc {/* Column header with drag-to-resize handles */}
- {(["white", "black", "result", "year"] as ColKey[]).map((k) => ( + {COL_ORDER.map((k) => (
- {k === "white" ? "White" : k === "black" ? "Black" : k === "result" ? "Result" : "Year"} + {COL_LABEL[k]} startResize(k, e)} className="absolute top-0 right-0 h-full w-1.5 cursor-col-resize hover:bg-primary/40" @@ -620,7 +634,8 @@ export default function GamesPage({ scopePublicOnly, scopeCollectionId, scopeInc {game.white}{game.white_elo ? {game.white_elo} : null} {game.black}{game.black_elo ? {game.black_elo} : null} {game.result ? (game.result === "1/2-1/2" ? "½-½" : game.result) : ""} - {game.date?.slice(0, 4) ?? ""} + {displayDate(game.date)} + {displayRound(game.round)} {game.event ?? ""} ); diff --git a/chess-client/src/types.ts b/chess-client/src/types.ts index 19a24ff..ba91b4f 100644 --- a/chess-client/src/types.ts +++ b/chess-client/src/types.ts @@ -13,6 +13,8 @@ export interface GameSummary { visibility?: string | null; /** Soft-delete timestamp (ISO). Null = alive. Only populated when include_deleted=true. */ deleted_at?: string | null; + /** PGN Round tag ("5", "3.2", "?"). */ + round?: string | null; move_number?: number | null; } diff --git a/chess-db/src/serve.rs b/chess-db/src/serve.rs index a0a8db1..24a9e0f 100644 --- a/chess-db/src/serve.rs +++ b/chess-db/src/serve.rs @@ -155,6 +155,8 @@ pub struct GameSummary { pub visibility: Option, /// Soft-delete timestamp; only populated when `include_deleted=true` is requested. pub deleted_at: Option, + /// The PGN Round tag as imported ("5", "3.2", "?"). + pub round: Option, #[serde(skip_serializing_if = "Option::is_none")] pub pgn: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -392,6 +394,18 @@ fn strip_move_numbers(input: &str) -> String { // ── Shared query builder ────────────────────────────────────────────────────── +/// Game-list order: newest date first, then latest round first, so games played +/// on the same day list in the order they were played (reversed, like the dates). +/// Round is a free-text PGN tag ("5", "3.2", "?"), so it is compared numerically +/// on its round and sub-round parts — as text "10" would sort before "9". +/// Unknown date parts ("2026-??-??") sort as zero, below that year's dated +/// games — as text "?" sorts above every digit. `g.id` breaks remaining ties so +/// paging never skips or repeats a game. +const GAME_LIST_ORDER: &str = "replace(g.date, '?', '0') DESC NULLS LAST, \ + TRY_CAST(split_part(g.round, '.', 1) AS INTEGER) DESC NULLS LAST, \ + TRY_CAST(split_part(g.round, '.', 2) AS INTEGER) DESC NULLS LAST, \ + g.id DESC"; + #[allow(clippy::too_many_arguments)] fn build_games_sql( name: Option<&str>, @@ -495,14 +509,14 @@ fn build_games_sql( } return (format!( "SELECT g.id, pw.name, pb.name, g.white_elo, g.black_elo, - g.event, g.date, g.result, g.eco, g.move_count, g.opening_line, g.visibility, CAST(g.deleted_at AS VARCHAR){pgn_col}{move_num_col} + g.event, g.date, g.result, g.eco, g.move_count, g.opening_line, g.visibility, CAST(g.deleted_at AS VARCHAR), g.round{pgn_col}{move_num_col} FROM games g JOIN players pw ON g.white_id = pw.id JOIN players pb ON g.black_id = pb.id {pos_join} WHERE {players_filter} {date_from_filter} {date_to_filter} {event_filter} {eco_filter} {moves_filter} {source_filter} {collection_filter} {visibility_filter} {deleted_filter} {fen_filter} - ORDER BY g.date DESC NULLS LAST LIMIT ? OFFSET ?" + ORDER BY {GAME_LIST_ORDER} LIMIT ? OFFSET ?" ), params); } else if name.is_some() || fide_id.is_some() { let color_filter = match color.unwrap_or("any") { @@ -535,7 +549,7 @@ fn build_games_sql( } else { format!( "SELECT g.id, pw.name, pb.name, g.white_elo, g.black_elo, - g.event, g.date, g.result, g.eco, g.move_count, g.opening_line, g.visibility, CAST(g.deleted_at AS VARCHAR){pgn_col}{move_num_col} + g.event, g.date, g.result, g.eco, g.move_count, g.opening_line, g.visibility, CAST(g.deleted_at AS VARCHAR), g.round{pgn_col}{move_num_col} FROM games g JOIN players p ON (g.white_id = p.id OR g.black_id = p.id) JOIN players pw ON g.white_id = pw.id @@ -543,7 +557,7 @@ fn build_games_sql( {pos_join} WHERE 1=1 {player_filter} {color_filter} {date_from_filter} {date_to_filter} {event_filter} {eco_filter} {moves_filter} {source_filter} {collection_filter} {visibility_filter} {deleted_filter} {fen_filter} - ORDER BY g.date DESC NULLS LAST LIMIT ? OFFSET ?" + ORDER BY {GAME_LIST_ORDER} LIMIT ? OFFSET ?" ) } } else { @@ -582,14 +596,14 @@ fn build_games_sql( } else { format!( "SELECT g.id, pw.name, pb.name, g.white_elo, g.black_elo, - g.event, g.date, g.result, g.eco, g.move_count, g.opening_line, g.visibility, CAST(g.deleted_at AS VARCHAR){pgn_col}{move_num_col} + g.event, g.date, g.result, g.eco, g.move_count, g.opening_line, g.visibility, CAST(g.deleted_at AS VARCHAR), g.round{pgn_col}{move_num_col} FROM games g JOIN players pw ON g.white_id = pw.id JOIN players pb ON g.black_id = pb.id {pos_join} WHERE {where_clause} {date_from_filter} {date_to_filter} {event_filter} {eco_filter} {moves_filter} {source_filter} {collection_filter} {visibility_filter} {deleted_filter} {fen_filter} - ORDER BY g.date DESC NULLS LAST LIMIT ? OFFSET ?" + ORDER BY {GAME_LIST_ORDER} LIMIT ? OFFSET ?" ) } }; @@ -1008,10 +1022,10 @@ async fn games_handler( return Ok(Json(serde_json::json!({ "count": n }))); } - // Column layout: 0-10 fixed, visibility at 11, deleted_at at 12, - // then optional pgn (13), then optional move_number. - let pgn_col: i32 = if q.pgn { 13 } else { -1 }; - let move_num_col: i32 = if fen_hash.is_some() { if q.pgn { 14 } else { 13 } } else { -1 }; + // Column layout: 0-10 fixed, visibility at 11, deleted_at at 12, round + // at 13, then optional pgn (14), then optional move_number. + let pgn_col: i32 = if q.pgn { 14 } else { -1 }; + let move_num_col: i32 = if fen_hash.is_some() { if q.pgn { 15 } else { 14 } } else { -1 }; let mut stmt = conn.prepare(&sql).map_err(db_err)?; let rows = stmt.query_map(params_ref.as_slice(), |row| { @@ -1022,6 +1036,7 @@ async fn games_handler( eco: row.get(8)?, move_count: row.get(9)?, opening_line: row.get(10)?, visibility: row.get(11)?, deleted_at: row.get(12)?, + round: row.get(13)?, pgn: if pgn_col >= 0 { row.get(pgn_col as usize)? } else { None }, move_number: if move_num_col >= 0 { Some(row.get(move_num_col as usize)?) } else { None }, })