From a2f1adebe9f93560a5d859f1dadf29a4794eb47a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jozef=20Svr=C4=8Dek?= <24891922+jozef2svrcek@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:09:59 +0200 Subject: [PATCH 1/3] Show the full date and round in the game list, sorted by both The Players and Games pages showed only the year, and games on the same day came back in no particular order. - Replace the Year column with Date (the known part: 2026-03-15, 2026-03 or 2026) and add a Round column; saved column widths are kept. - /games returns each game's round and orders by date, then round (as numbers on its round and sub-round parts, so 10 sorts before 9), then id so paging is stable. - Unknown date parts sort as zero: "2026-??-??" sorted above every dated 2026 game, since "?" sorts after digits. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FzbThuPFuAz9VYWoZd4ESu --- chess-client/src/components/GamesPage.tsx | 33 +++++++++++++++------ chess-client/src/types.ts | 2 ++ chess-db/src/serve.rs | 35 ++++++++++++++++------- 3 files changed, 51 insertions(+), 19 deletions(-) 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 }, }) From 7cf9b7035c9d28e32eb753c6de38653f4eadfbab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jozef=20Svr=C4=8Dek?= <24891922+jozef2svrcek@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:10:18 +0200 Subject: [PATCH 2/3] CHANGELOG: full date and round in game lists (#292) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FzbThuPFuAz9VYWoZd4ESu --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6edc17f..1d4f074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ 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) + ## [0.17.0] - 2026-09-14 ### Fixed From 0b62b91694f3615f33ec27690bdbcbee3fef46a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jozef=20Svr=C4=8Dek?= <24891922+jozef2svrcek@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:52:57 +0200 Subject: [PATCH 3/3] Engine evals from White's side, coloured in both lists; clear the rail scrollbar - chessdb.cn scores are side-to-move relative; show them White-relative like Lichess, so negative always means Black is better. - Colour Lichess evals as well as chessdb ones. Green/red means good/bad for the player to move, matching the !/? marks, not the displayed sign. - Pad the Analysis page's column of open games: WebKitGTK draws the scrollbar over the content, where it covered each card's close button. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WhMko1N4uhAAyTjAkBcqAS --- CHANGELOG.md | 12 ++++++++++ chess-client/src/components/AnalysisPage.tsx | 4 +++- chess-client/src/components/CloudEngine.tsx | 25 ++++++++++++-------- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d4f074..99d30a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 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)}
); })}