Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion chess-client/src/components/AnalysisPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ export default function AnalysisPage({ tabs, activeKey, onActivate, onClose, onO
minSize={rz.floor("rail") ?? "5"}
maxSize="16"
>
<div className="h-full flex flex-col gap-1.5 overflow-y-auto">
{/* Right padding keeps the cards clear of the scrollbar, which WebKitGTK
draws over the content instead of beside it — it hid the close ✕. */}
<div className="h-full flex flex-col gap-1.5 overflow-y-auto pr-2.5">
{tabs.map((t) => {
const on = t.key === activeKey;
return (
Expand Down
25 changes: 15 additions & 10 deletions chess-client/src/components/CloudEngine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -386,7 +391,7 @@ export default function CloudEngine({ fen, watchLabel, onPlayLine }: Props) {
</div>
<span className="shrink-0 w-12 text-right tabular-nums text-body-sm text-on-surface-variant">{nn ? Number(nn.opp) : "—"}</span>
<span className="shrink-0 w-12 text-right tabular-nums text-body-sm text-on-surface">{nn ? Number(nn.oppStrong) : "—"}</span>
<span className={`shrink-0 w-14 text-right tabular-nums text-body-sm ${evalColor(m)}`}>{fmtEval(m)}</span>
<span className={`shrink-0 w-14 text-right tabular-nums font-mono text-body-sm ${evalColor(m.mate ?? m.scoreCp)}`}>{fmtEval(m, fen.split(" ")[1] !== "b")}</span>
</div>
);
})}
Expand Down Expand Up @@ -463,7 +468,7 @@ export default function CloudEngine({ fen, watchLabel, onPlayLine }: Props) {
</div>
{lichessShowStats && <span className="shrink-0 w-12 text-right tabular-nums text-body-sm text-on-surface-variant">{st ? st.replies : "—"}</span>}
{lichessShowStats && <span className="shrink-0 w-12 text-right tabular-nums text-body-sm text-on-surface">{st ? st.strong : "—"}</span>}
<span className="shrink-0 w-14 text-right tabular-nums font-mono text-body-sm text-on-surface">{fmtLichess(l)}</span>
<span className={`shrink-0 w-14 text-right tabular-nums font-mono text-body-sm ${evalColor(lmScores[i])}`}>{fmtLichess(l)}</span>
</div>
);
})}
Expand Down
33 changes: 24 additions & 9 deletions chess-client/src/components/GamesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ColKey, string> = { white: "White", black: "Black", result: "Result", date: "Date", round: "Round" };
const COL_MIN = 36;
const [colW, setColW] = useState<Record<ColKey, number>>(() => {
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
Expand Down Expand Up @@ -589,9 +603,9 @@ export default function GamesPage({ scopePublicOnly, scopeCollectionId, scopeInc
</div>
{/* Column header with drag-to-resize handles */}
<div className="shrink-0 grid items-center text-label-sm text-on-surface-variant border-b border-outline/40 select-none" style={{ gridTemplateColumns: gridCols }}>
{(["white", "black", "result", "year"] as ColKey[]).map((k) => (
{COL_ORDER.map((k) => (
<div key={k} className="relative px-3 py-1 truncate">
{k === "white" ? "White" : k === "black" ? "Black" : k === "result" ? "Result" : "Year"}
{COL_LABEL[k]}
<span
onMouseDown={(e) => startResize(k, e)}
className="absolute top-0 right-0 h-full w-1.5 cursor-col-resize hover:bg-primary/40"
Expand Down Expand Up @@ -620,7 +634,8 @@ export default function GamesPage({ scopePublicOnly, scopeCollectionId, scopeInc
<span className="px-3 py-1.5 truncate">{game.white}{game.white_elo ? <span className={subText}> {game.white_elo}</span> : null}</span>
<span className="px-3 py-1.5 truncate">{game.black}{game.black_elo ? <span className={subText}> {game.black_elo}</span> : null}</span>
<span className="px-3 py-1.5 truncate tabular-nums">{game.result ? (game.result === "1/2-1/2" ? "½-½" : game.result) : ""}</span>
<span className={`px-3 py-1.5 truncate ${subText}`}>{game.date?.slice(0, 4) ?? ""}</span>
<span className={`px-3 py-1.5 truncate tabular-nums ${subText}`}>{displayDate(game.date)}</span>
<span className={`px-3 py-1.5 truncate tabular-nums ${subText}`}>{displayRound(game.round)}</span>
<span className={`px-3 py-1.5 truncate ${subText}`}>{game.event ?? ""}</span>
</button>
);
Expand Down
2 changes: 2 additions & 0 deletions chess-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
35 changes: 25 additions & 10 deletions chess-db/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ pub struct GameSummary {
pub visibility: Option<String>,
/// Soft-delete timestamp; only populated when `include_deleted=true` is requested.
pub deleted_at: Option<String>,
/// The PGN Round tag as imported ("5", "3.2", "?").
pub round: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pgn: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -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>,
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -535,15 +549,15 @@ 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
JOIN players pb ON g.black_id = pb.id
{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 {
Expand Down Expand Up @@ -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 ?"
)
}
};
Expand Down Expand Up @@ -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| {
Expand All @@ -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 },
})
Expand Down
Loading