Move History - RumenDamyanov/js-chess GitHub Wiki

Move History

Comprehensive guide to implementing and displaying chess move history across all framework implementations.

Overview

Move history is a crucial component of any chess application, providing:

  • Complete game record in algebraic notation
  • Navigation through moves (forward/backward)
  • Move analysis and annotations
  • Export capabilities (PGN format)
  • Visual move highlighting
  • Statistical analysis

Core Move History System

Move Data Structure

// shared/models/Move.js
export class ChessMove {
  constructor(data) {
    this.from = data.from;           // e.g., 'e2'
    this.to = data.to;               // e.g., 'e4'
    this.piece = data.piece;         // e.g., 'P' (white pawn)
    this.captured = data.captured;   // e.g., 'p' (black pawn) or null
    this.promotion = data.promotion; // e.g., 'Q' or null
    this.castling = data.castling;   // 'kingside', 'queenside', or null
    this.enPassant = data.enPassant || false;
    this.check = data.check || false;
    this.checkmate = data.checkmate || false;
    this.stalemate = data.stalemate || false;
    this.timestamp = data.timestamp || new Date();
    this.moveNumber = data.moveNumber;
    this.annotation = data.annotation || '';
    this.evaluation = data.evaluation || null; // For engine analysis
  }

  // Convert to algebraic notation
  toAlgebraic() {
    if (this.castling) {
      return this.castling === 'kingside' ? 'O-O' : 'O-O-O';
    }

    let notation = '';

    // Piece notation (except pawns)
    if (this.piece.toUpperCase() !== 'P') {
      notation += this.piece.toUpperCase();
    }

    // Capture notation
    if (this.captured) {
      if (this.piece.toUpperCase() === 'P') {
        notation += this.from[0]; // File of capturing pawn
      }
      notation += 'x';
    }

    // Destination square
    notation += this.to;

    // Promotion
    if (this.promotion) {
      notation += '=' + this.promotion.toUpperCase();
    }

    // En passant
    if (this.enPassant) {
      notation += ' e.p.';
    }

    // Check/checkmate
    if (this.checkmate) {
      notation += '#';
    } else if (this.check) {
      notation += '+';
    }

    return notation;
  }

  // Convert to long algebraic notation
  toLongAlgebraic() {
    if (this.castling) {
      return this.castling === 'kingside' ? 'O-O' : 'O-O-O';
    }

    let notation = '';

    // Piece
    if (this.piece.toUpperCase() !== 'P') {
      notation += this.piece.toUpperCase();
    }

    // Origin square
    notation += this.from;

    // Capture or move
    notation += this.captured ? 'x' : '-';

    // Destination
    notation += this.to;

    // Promotion
    if (this.promotion) {
      notation += '=' + this.promotion.toUpperCase();
    }

    // Check/checkmate
    if (this.checkmate) {
      notation += '#';
    } else if (this.check) {
      notation += '+';
    }

    return notation;
  }

  // Clone move
  clone() {
    return new ChessMove(this);
  }
}

Move History Manager

// shared/services/MoveHistoryManager.js
export class MoveHistoryManager {
  constructor() {
    this.moves = [];
    this.currentMoveIndex = -1;
    this.variations = new Map(); // For move variations/branches
    this.annotations = new Map(); // Move annotations
  }

  addMove(move) {
    // If we're not at the end of history, truncate future moves
    if (this.currentMoveIndex < this.moves.length - 1) {
      this.moves = this.moves.slice(0, this.currentMoveIndex + 1);
    }

    // Add the new move
    const moveNumber = Math.floor(this.moves.length / 2) + 1;
    const chessMove = new ChessMove({
      ...move,
      moveNumber,
      timestamp: new Date()
    });

    this.moves.push(chessMove);
    this.currentMoveIndex = this.moves.length - 1;

    return chessMove;
  }

  goToMove(index) {
    if (index >= -1 && index < this.moves.length) {
      this.currentMoveIndex = index;
      return true;
    }
    return false;
  }

  goToStart() {
    return this.goToMove(-1);
  }

  goToEnd() {
    return this.goToMove(this.moves.length - 1);
  }

  goBack() {
    return this.goToMove(this.currentMoveIndex - 1);
  }

  goForward() {
    return this.goToMove(this.currentMoveIndex + 1);
  }

  getCurrentMove() {
    if (this.currentMoveIndex >= 0 && this.currentMoveIndex < this.moves.length) {
      return this.moves[this.currentMoveIndex];
    }
    return null;
  }

  getAllMoves() {
    return [...this.moves];
  }

  getMovesUpToCurrent() {
    return this.moves.slice(0, this.currentMoveIndex + 1);
  }

  canGoBack() {
    return this.currentMoveIndex > -1;
  }

  canGoForward() {
    return this.currentMoveIndex < this.moves.length - 1;
  }

  clear() {
    this.moves = [];
    this.currentMoveIndex = -1;
    this.variations.clear();
    this.annotations.clear();
  }

  // Get formatted move list
  getFormattedMoves(format = 'algebraic') {
    const formatted = [];

    for (let i = 0; i < this.moves.length; i += 2) {
      const whiteMove = this.moves[i];
      const blackMove = this.moves[i + 1];

      const moveNumber = whiteMove.moveNumber;
      let moveText = `${moveNumber}. `;

      if (format === 'algebraic') {
        moveText += whiteMove.toAlgebraic();
        if (blackMove) {
          moveText += ` ${blackMove.toAlgebraic()}`;
        }
      } else if (format === 'long') {
        moveText += whiteMove.toLongAlgebraic();
        if (blackMove) {
          moveText += ` ${blackMove.toLongAlgebraic()}`;
        }
      }

      formatted.push({
        moveNumber,
        whiteMove,
        blackMove,
        text: moveText,
        isCurrentWhite: i === this.currentMoveIndex,
        isCurrentBlack: blackMove && (i + 1) === this.currentMoveIndex
      });
    }

    return formatted;
  }

  // Export to PGN format
  toPGN(gameInfo = {}) {
    const headers = {
      Event: gameInfo.event || '?',
      Site: gameInfo.site || '?',
      Date: gameInfo.date || new Date().toISOString().split('T')[0],
      Round: gameInfo.round || '?',
      White: gameInfo.white || '?',
      Black: gameInfo.black || '?',
      Result: gameInfo.result || '*'
    };

    let pgn = '';

    // Add headers
    Object.entries(headers).forEach(([key, value]) => {
      pgn += `[${key} "${value}"]\n`;
    });

    pgn += '\n';

    // Add moves
    const formattedMoves = this.getFormattedMoves('algebraic');
    formattedMoves.forEach(move => {
      pgn += move.text + ' ';
    });

    // Add result
    pgn += headers.Result;

    return pgn.trim();
  }

  // Import from PGN
  fromPGN(pgnText) {
    this.clear();

    // Basic PGN parsing (simplified)
    const lines = pgnText.split('\n');
    let moveText = '';

    // Skip headers and find move text
    let inMoves = false;
    for (const line of lines) {
      if (line.trim() === '') {
        inMoves = true;
        continue;
      }

      if (inMoves && !line.startsWith('[')) {
        moveText += line + ' ';
      }
    }

    // Parse moves (basic implementation)
    const movePattern = /\d+\.\s*([^\s]+)(?:\s+([^\s]+))?/g;
    let match;

    while ((match = movePattern.exec(moveText)) !== null) {
      const whiteMove = this.parseAlgebraicMove(match[1]);
      if (whiteMove) {
        this.addMove(whiteMove);
      }

      if (match[2] && !match[2].includes('*') && !match[2].includes('-')) {
        const blackMove = this.parseAlgebraicMove(match[2]);
        if (blackMove) {
          this.addMove(blackMove);
        }
      }
    }

    this.goToEnd();
  }

  parseAlgebraicMove(notation) {
    // Simplified algebraic notation parsing
    // This would need to be more comprehensive for production use

    // Remove check/checkmate indicators
    const clean = notation.replace(/[+#]$/, '');

    // Handle castling
    if (clean === 'O-O') {
      return { castling: 'kingside' };
    }
    if (clean === 'O-O-O') {
      return { castling: 'queenside' };
    }

    // Basic move parsing (needs expansion)
    const moveMatch = clean.match(/^([NBRQK])?([a-h]?[1-8]?)(x)?([a-h][1-8])(=([NBRQ]))?$/);

    if (moveMatch) {
      return {
        piece: moveMatch[1] || 'P',
        to: moveMatch[4],
        captured: !!moveMatch[3],
        promotion: moveMatch[6] || null
      };
    }

    return null;
  }

  // Add variation/branch
  addVariation(moveIndex, moves) {
    if (!this.variations.has(moveIndex)) {
      this.variations.set(moveIndex, []);
    }
    this.variations.get(moveIndex).push(moves);
  }

  // Add annotation to move
  addAnnotation(moveIndex, annotation) {
    this.annotations.set(moveIndex, annotation);
  }

  getAnnotation(moveIndex) {
    return this.annotations.get(moveIndex) || '';
  }
}

Framework Implementations

Angular Move History Component

// angular-chess/src/app/components/move-history/move-history.component.ts
import { Component, Input, Output, EventEmitter, OnChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MoveHistoryManager, ChessMove } from '../../../shared/services/MoveHistoryManager';

@Component({
  selector: 'app-move-history',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="move-history">
      <div class="move-history-header">
        <h3>Move History</h3>
        <div class="move-controls">
          <button
            (click)="goToStart()"
            [disabled]="!canGoBack()"
            class="control-btn">

          </button>
          <button
            (click)="goBack()"
            [disabled]="!canGoBack()"
            class="control-btn">

          </button>
          <button
            (click)="goForward()"
            [disabled]="!canGoForward()"
            class="control-btn">

          </button>
          <button
            (click)="goToEnd()"
            [disabled]="!canGoForward()"
            class="control-btn">

          </button>
        </div>
      </div>

      <div class="move-list" #moveList>
        <div
          *ngFor="let move of formattedMoves; index as i"
          class="move-pair"
          [class.has-current]="move.isCurrentWhite || move.isCurrentBlack">

          <span class="move-number">{{ move.moveNumber }}.</span>

          <button
            class="move-button white-move"
            [class.current]="move.isCurrentWhite"
            (click)="goToMoveIndex(i * 2)">
            {{ move.whiteMove.toAlgebraic() }}
          </button>

          <button
            *ngIf="move.blackMove"
            class="move-button black-move"
            [class.current]="move.isCurrentBlack"
            (click)="goToMoveIndex(i * 2 + 1)">
            {{ move.blackMove.toAlgebraic() }}
          </button>

          <!-- Move annotations -->
          <span
            *ngIf="getAnnotation(i * 2)"
            class="move-annotation">
            {{ getAnnotation(i * 2) }}
          </span>
        </div>
      </div>

      <div class="move-history-footer">
        <div class="move-count">
          Moves: {{ moveCount }}
        </div>
        <div class="export-controls">
          <button (click)="exportPGN()" class="export-btn">
            Export PGN
          </button>
          <button (click)="copyMoves()" class="export-btn">
            Copy Moves
          </button>
        </div>
      </div>
    </div>
  `,
  styleUrls: ['./move-history.component.css']
})
export class MoveHistoryComponent implements OnChanges {
  @Input() historyManager!: MoveHistoryManager;
  @Input() currentMoveIndex: number = -1;
  @Output() moveSelected = new EventEmitter<number>();
  @Output() navigationRequest = new EventEmitter<'start' | 'back' | 'forward' | 'end'>();

  formattedMoves: any[] = [];
  moveCount = 0;

  ngOnChanges() {
    if (this.historyManager) {
      this.updateDisplay();
    }
  }

  updateDisplay() {
    this.formattedMoves = this.historyManager.getFormattedMoves();
    this.moveCount = this.historyManager.getAllMoves().length;
  }

  goToMoveIndex(index: number) {
    this.moveSelected.emit(index);
  }

  goToStart() {
    this.navigationRequest.emit('start');
  }

  goBack() {
    this.navigationRequest.emit('back');
  }

  goForward() {
    this.navigationRequest.emit('forward');
  }

  goToEnd() {
    this.navigationRequest.emit('end');
  }

  canGoBack(): boolean {
    return this.historyManager?.canGoBack() || false;
  }

  canGoForward(): boolean {
    return this.historyManager?.canGoForward() || false;
  }

  getAnnotation(moveIndex: number): string {
    return this.historyManager?.getAnnotation(moveIndex) || '';
  }

  exportPGN() {
    const pgn = this.historyManager.toPGN({
      event: 'Chess Game',
      site: 'Web Browser',
      white: 'Player 1',
      black: 'Player 2'
    });

    this.downloadFile(pgn, 'game.pgn', 'text/plain');
  }

  copyMoves() {
    const moves = this.formattedMoves.map(m => m.text).join(' ');
    navigator.clipboard.writeText(moves).then(() => {
      console.log('Moves copied to clipboard');
    });
  }

  private downloadFile(content: string, filename: string, contentType: string) {
    const blob = new Blob([content], { type: contentType });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = filename;
    link.click();
    URL.revokeObjectURL(url);
  }
}

React Move History Component

// react-chess/src/components/MoveHistory.jsx
import React, { useMemo, useCallback } from 'react';
import { MoveHistoryManager } from '../../shared/services/MoveHistoryManager';
import './MoveHistory.css';

const MoveHistory = ({
  historyManager,
  currentMoveIndex,
  onMoveSelected,
  onNavigationRequest
}) => {
  const formattedMoves = useMemo(() => {
    return historyManager ? historyManager.getFormattedMoves() : [];
  }, [historyManager, currentMoveIndex]);

  const moveCount = useMemo(() => {
    return historyManager ? historyManager.getAllMoves().length : 0;
  }, [historyManager, currentMoveIndex]);

  const canGoBack = useMemo(() => {
    return historyManager ? historyManager.canGoBack() : false;
  }, [historyManager, currentMoveIndex]);

  const canGoForward = useMemo(() => {
    return historyManager ? historyManager.canGoForward() : false;
  }, [historyManager, currentMoveIndex]);

  const handleMoveClick = useCallback((index) => {
    onMoveSelected?.(index);
  }, [onMoveSelected]);

  const handleNavigation = useCallback((direction) => {
    onNavigationRequest?.(direction);
  }, [onNavigationRequest]);

  const exportPGN = useCallback(() => {
    if (!historyManager) return;

    const pgn = historyManager.toPGN({
      event: 'Chess Game',
      site: 'Web Browser',
      white: 'Player 1',
      black: 'Player 2'
    });

    downloadFile(pgn, 'game.pgn', 'text/plain');
  }, [historyManager]);

  const copyMoves = useCallback(async () => {
    if (!historyManager) return;

    const moves = formattedMoves.map(m => m.text).join(' ');
    try {
      await navigator.clipboard.writeText(moves);
      console.log('Moves copied to clipboard');
    } catch (err) {
      console.error('Failed to copy moves:', err);
    }
  }, [formattedMoves]);

  const downloadFile = (content, filename, contentType) => {
    const blob = new Blob([content], { type: contentType });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = filename;
    link.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div className="move-history">
      <div className="move-history-header">
        <h3>Move History</h3>
        <div className="move-controls">
          <button
            onClick={() => handleNavigation('start')}
            disabled={!canGoBack}
            className="control-btn"></button>
          <button
            onClick={() => handleNavigation('back')}
            disabled={!canGoBack}
            className="control-btn"></button>
          <button
            onClick={() => handleNavigation('forward')}
            disabled={!canGoForward}
            className="control-btn"></button>
          <button
            onClick={() => handleNavigation('end')}
            disabled={!canGoForward}
            className="control-btn"></button>
        </div>
      </div>

      <div className="move-list">
        {formattedMoves.map((move, i) => (
          <div
            key={i}
            className={`move-pair ${move.isCurrentWhite || move.isCurrentBlack ? 'has-current' : ''}`}>

            <span className="move-number">{move.moveNumber}.</span>

            <button
              className={`move-button white-move ${move.isCurrentWhite ? 'current' : ''}`}
              onClick={() => handleMoveClick(i * 2)}>
              {move.whiteMove.toAlgebraic()}
            </button>

            {move.blackMove && (
              <button
                className={`move-button black-move ${move.isCurrentBlack ? 'current' : ''}`}
                onClick={() => handleMoveClick(i * 2 + 1)}>
                {move.blackMove.toAlgebraic()}
              </button>
            )}

            {historyManager?.getAnnotation(i * 2) && (
              <span className="move-annotation">
                {historyManager.getAnnotation(i * 2)}
              </span>
            )}
          </div>
        ))}
      </div>

      <div className="move-history-footer">
        <div className="move-count">
          Moves: {moveCount}
        </div>
        <div className="export-controls">
          <button onClick={exportPGN} className="export-btn">
            Export PGN
          </button>
          <button onClick={copyMoves} className="export-btn">
            Copy Moves
          </button>
        </div>
      </div>
    </div>
  );
};

export default MoveHistory;

Vue Move History Component

<!-- vue-chess/src/components/MoveHistory.vue -->
<template>
  <div class="move-history">
    <div class="move-history-header">
      <h3>Move History</h3>
      <div class="move-controls">
        <button
          @click="handleNavigation('start')"
          :disabled="!canGoBack"
          class="control-btn">
          ⏮
        </button>
        <button
          @click="handleNavigation('back')"
          :disabled="!canGoBack"
          class="control-btn">
          ◀
        </button>
        <button
          @click="handleNavigation('forward')"
          :disabled="!canGoForward"
          class="control-btn">
          ▶
        </button>
        <button
          @click="handleNavigation('end')"
          :disabled="!canGoForward"
          class="control-btn">
          ⏭
        </button>
      </div>
    </div>

    <div class="move-list">
      <div
        v-for="(move, i) in formattedMoves"
        :key="i"
        class="move-pair"
        :class="{ 'has-current': move.isCurrentWhite || move.isCurrentBlack }">

        <span class="move-number">{{ move.moveNumber }}.</span>

        <button
          class="move-button white-move"
          :class="{ current: move.isCurrentWhite }"
          @click="handleMoveClick(i * 2)">
          {{ move.whiteMove.toAlgebraic() }}
        </button>

        <button
          v-if="move.blackMove"
          class="move-button black-move"
          :class="{ current: move.isCurrentBlack }"
          @click="handleMoveClick(i * 2 + 1)">
          {{ move.blackMove.toAlgebraic() }}
        </button>

        <span
          v-if="getAnnotation(i * 2)"
          class="move-annotation">
          {{ getAnnotation(i * 2) }}
        </span>
      </div>
    </div>

    <div class="move-history-footer">
      <div class="move-count">
        Moves: {{ moveCount }}
      </div>
      <div class="export-controls">
        <button @click="exportPGN" class="export-btn">
          Export PGN
        </button>
        <button @click="copyMoves" class="export-btn">
          Copy Moves
        </button>
      </div>
    </div>
  </div>
</template>

<script>
import { computed } from 'vue';

export default {
  name: 'MoveHistory',
  props: {
    historyManager: Object,
    currentMoveIndex: {
      type: Number,
      default: -1
    }
  },
  emits: ['move-selected', 'navigation-request'],
  setup(props, { emit }) {
    const formattedMoves = computed(() => {
      return props.historyManager ? props.historyManager.getFormattedMoves() : [];
    });

    const moveCount = computed(() => {
      return props.historyManager ? props.historyManager.getAllMoves().length : 0;
    });

    const canGoBack = computed(() => {
      return props.historyManager ? props.historyManager.canGoBack() : false;
    });

    const canGoForward = computed(() => {
      return props.historyManager ? props.historyManager.canGoForward() : false;
    });

    const handleMoveClick = (index) => {
      emit('move-selected', index);
    };

    const handleNavigation = (direction) => {
      emit('navigation-request', direction);
    };

    const getAnnotation = (moveIndex) => {
      return props.historyManager?.getAnnotation(moveIndex) || '';
    };

    const exportPGN = () => {
      if (!props.historyManager) return;

      const pgn = props.historyManager.toPGN({
        event: 'Chess Game',
        site: 'Web Browser',
        white: 'Player 1',
        black: 'Player 2'
      });

      downloadFile(pgn, 'game.pgn', 'text/plain');
    };

    const copyMoves = async () => {
      if (!props.historyManager) return;

      const moves = formattedMoves.value.map(m => m.text).join(' ');
      try {
        await navigator.clipboard.writeText(moves);
        console.log('Moves copied to clipboard');
      } catch (err) {
        console.error('Failed to copy moves:', err);
      }
    };

    const downloadFile = (content, filename, contentType) => {
      const blob = new Blob([content], { type: contentType });
      const url = URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;
      link.download = filename;
      link.click();
      URL.revokeObjectURL(url);
    };

    return {
      formattedMoves,
      moveCount,
      canGoBack,
      canGoForward,
      handleMoveClick,
      handleNavigation,
      getAnnotation,
      exportPGN,
      copyMoves
    };
  }
};
</script>

<style scoped src="./MoveHistory.css"></style>

Shared Styling

Move History CSS

/* shared/styles/move-history.css */
.move-history {
  width: 100%;
  max-width: 400px;
  height: 100%;
  display: flex;
  flex-direction: column;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 8px;
  overflow: hidden;
}

.move-history-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem;
  background: var(--color-surface-variant);
  border-bottom: 1px solid var(--color-border);
}

.move-history-header h3 {
  margin: 0;
  font-size: 1.1rem;
  font-weight: 600;
  color: var(--color-on-surface);
}

.move-controls {
  display: flex;
  gap: 0.25rem;
}

.control-btn {
  width: 32px;
  height: 32px;
  border: 1px solid var(--color-border);
  background: var(--color-surface);
  border-radius: 4px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.9rem;
  transition: all var(--transition-fast);
}

.control-btn:hover:not(:disabled) {
  background: var(--color-primary);
  color: var(--color-on-primary);
}

.control-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.move-list {
  flex: 1;
  overflow-y: auto;
  padding: 0.5rem;
}

.move-pair {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
  transition: background-color var(--transition-fast);
}

.move-pair:hover {
  background: var(--color-surface-variant);
}

.move-pair.has-current {
  background: var(--color-primary-container);
}

.move-number {
  min-width: 2rem;
  font-weight: 600;
  color: var(--color-on-surface-variant);
  font-size: 0.9rem;
}

.move-button {
  min-width: 3rem;
  height: 24px;
  padding: 0 0.5rem;
  border: 1px solid var(--color-border);
  background: var(--color-surface);
  border-radius: 4px;
  cursor: pointer;
  font-family: monospace;
  font-size: 0.85rem;
  transition: all var(--transition-fast);
}

.move-button:hover {
  background: var(--color-surface-variant);
}

.move-button.current {
  background: var(--color-primary);
  color: var(--color-on-primary);
  border-color: var(--color-primary);
  font-weight: 600;
}

.white-move {
  color: var(--color-on-surface);
}

.black-move {
  color: var(--color-on-surface-variant);
}

.move-annotation {
  font-size: 0.75rem;
  color: var(--color-primary);
  font-style: italic;
  margin-left: 0.5rem;
}

.move-history-footer {
  padding: 1rem;
  background: var(--color-surface-variant);
  border-top: 1px solid var(--color-border);
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.move-count {
  font-size: 0.9rem;
  color: var(--color-on-surface-variant);
}

.export-controls {
  display: flex;
  gap: 0.5rem;
}

.export-btn {
  padding: 0.25rem 0.75rem;
  border: 1px solid var(--color-border);
  background: var(--color-surface);
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.8rem;
  transition: all var(--transition-fast);
}

.export-btn:hover {
  background: var(--color-primary);
  color: var(--color-on-primary);
}

/* Responsive design */
@media (max-width: 768px) {
  .move-history {
    max-width: 100%;
  }

  .move-history-header {
    padding: 0.75rem;
  }

  .move-controls {
    gap: 0.125rem;
  }

  .control-btn {
    width: 28px;
    height: 28px;
    font-size: 0.8rem;
  }

  .move-pair {
    padding: 0.25rem;
    gap: 0.25rem;
  }

  .move-button {
    min-width: 2.5rem;
    height: 20px;
    font-size: 0.75rem;
  }

  .move-history-footer {
    padding: 0.75rem;
    flex-direction: column;
    gap: 0.5rem;
  }
}

/* Dark theme support */
@media (prefers-color-scheme: dark) {
  .move-history {
    background: var(--color-surface-dark);
    border-color: var(--color-border-dark);
  }

  .move-history-header,
  .move-history-footer {
    background: var(--color-surface-variant-dark);
    border-color: var(--color-border-dark);
  }

  .control-btn,
  .move-button,
  .export-btn {
    background: var(--color-surface-dark);
    border-color: var(--color-border-dark);
    color: var(--color-on-surface-dark);
  }
}

/* High contrast mode */
@media (prefers-contrast: high) {
  .move-button.current {
    border-width: 2px;
    font-weight: 700;
  }

  .move-pair.has-current {
    border: 2px solid var(--color-primary);
  }
}

/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
  .move-button,
  .control-btn,
  .export-btn,
  .move-pair {
    transition: none;
  }
}

Advanced Features

Move Analysis Integration

// shared/services/MoveAnalysis.js
export class MoveAnalysis {
  constructor(historyManager, chessEngine) {
    this.historyManager = historyManager;
    this.chessEngine = chessEngine;
    this.analysisCache = new Map();
  }

  async analyzeMove(moveIndex) {
    const cacheKey = `${moveIndex}`;

    if (this.analysisCache.has(cacheKey)) {
      return this.analysisCache.get(cacheKey);
    }

    const moves = this.historyManager.getMovesUpToCurrent();
    const position = this.chessEngine.getPositionAfterMoves(moves.slice(0, moveIndex));

    try {
      const analysis = await this.chessEngine.analyzePosition(position, {
        depth: 15,
        multiPV: 3
      });

      this.analysisCache.set(cacheKey, analysis);
      return analysis;
    } catch (error) {
      console.error('Move analysis failed:', error);
      return null;
    }
  }

  async analyzeGame() {
    const moves = this.historyManager.getAllMoves();
    const analysis = {
      accuracy: { white: 0, black: 0 },
      blunders: [],
      mistakes: [],
      inaccuracies: [],
      bestMoves: [],
      openingName: '',
      endgameClassification: ''
    };

    for (let i = 0; i < moves.length; i++) {
      const moveAnalysis = await this.analyzeMove(i);
      if (moveAnalysis) {
        this.categorizeMove(i, moveAnalysis, analysis);
      }
    }

    return analysis;
  }

  categorizeMove(moveIndex, analysis, gameAnalysis) {
    const evaluationDrop = this.calculateEvaluationDrop(analysis);

    if (evaluationDrop > 300) {
      gameAnalysis.blunders.push({
        moveIndex,
        evaluationDrop,
        bestMove: analysis.bestMove
      });
    } else if (evaluationDrop > 100) {
      gameAnalysis.mistakes.push({
        moveIndex,
        evaluationDrop,
        bestMove: analysis.bestMove
      });
    } else if (evaluationDrop > 50) {
      gameAnalysis.inaccuracies.push({
        moveIndex,
        evaluationDrop,
        bestMove: analysis.bestMove
      });
    }
  }

  calculateEvaluationDrop(analysis) {
    // Calculate how much the evaluation dropped after the move
    return Math.abs(analysis.evaluationBefore - analysis.evaluationAfter);
  }

  getOpeningName(moves) {
    // Look up opening name based on move sequence
    // This would use an opening database
    return 'Sicilian Defense';
  }
}

Interactive Move Tree

// shared/components/MoveTree.js
export class MoveTree {
  constructor(historyManager) {
    this.historyManager = historyManager;
    this.tree = this.buildTree();
  }

  buildTree() {
    const root = { move: null, children: [], parent: null };
    let current = root;

    const moves = this.historyManager.getAllMoves();

    moves.forEach(move => {
      const node = {
        move,
        children: [],
        parent: current,
        evaluation: move.evaluation,
        annotation: move.annotation
      };

      current.children.push(node);
      current = node;
    });

    return root;
  }

  addVariation(afterMoveIndex, variationMoves) {
    // Add alternative line from a specific point
    const targetNode = this.findNode(afterMoveIndex);

    if (targetNode) {
      let current = targetNode;

      variationMoves.forEach(move => {
        const node = {
          move,
          children: [],
          parent: current,
          isVariation: true
        };

        current.children.push(node);
        current = node;
      });
    }
  }

  findNode(moveIndex) {
    // Find node at specific move index
    let current = this.tree;
    let index = 0;

    while (current.children.length > 0 && index <= moveIndex) {
      current = current.children[0]; // Follow main line
      if (index === moveIndex) {
        return current;
      }
      index++;
    }

    return null;
  }

  renderTree() {
    // Return tree structure for visualization
    return this.renderNode(this.tree, 0);
  }

  renderNode(node, depth) {
    if (!node.move) {
      // Root node
      return {
        type: 'root',
        children: node.children.map(child => this.renderNode(child, depth + 1))
      };
    }

    return {
      type: 'move',
      move: node.move.toAlgebraic(),
      evaluation: node.evaluation,
      annotation: node.annotation,
      depth,
      isVariation: node.isVariation || false,
      children: node.children.map(child => this.renderNode(child, depth + 1))
    };
  }
}

Testing Move History

Unit Tests

// shared/__tests__/MoveHistoryManager.test.js
import { MoveHistoryManager, ChessMove } from '../services/MoveHistoryManager';

describe('MoveHistoryManager', () => {
  let manager;

  beforeEach(() => {
    manager = new MoveHistoryManager();
  });

  test('should add moves and track current position', () => {
    const move1 = { from: 'e2', to: 'e4', piece: 'P' };
    const move2 = { from: 'e7', to: 'e5', piece: 'p' };

    manager.addMove(move1);
    manager.addMove(move2);

    expect(manager.getAllMoves()).toHaveLength(2);
    expect(manager.currentMoveIndex).toBe(1);
  });

  test('should navigate through move history', () => {
    const moves = [
      { from: 'e2', to: 'e4', piece: 'P' },
      { from: 'e7', to: 'e5', piece: 'p' },
      { from: 'g1', to: 'f3', piece: 'N' }
    ];

    moves.forEach(move => manager.addMove(move));

    expect(manager.canGoBack()).toBe(true);
    expect(manager.canGoForward()).toBe(false);

    manager.goBack();
    expect(manager.currentMoveIndex).toBe(1);
    expect(manager.canGoForward()).toBe(true);

    manager.goToStart();
    expect(manager.currentMoveIndex).toBe(-1);
    expect(manager.canGoBack()).toBe(false);
  });

  test('should truncate history when adding move mid-game', () => {
    const moves = [
      { from: 'e2', to: 'e4', piece: 'P' },
      { from: 'e7', to: 'e5', piece: 'p' },
      { from: 'g1', to: 'f3', piece: 'N' }
    ];

    moves.forEach(move => manager.addMove(move));

    // Go back to first move
    manager.goToMove(0);

    // Add different move
    const newMove = { from: 'd2', to: 'd4', piece: 'P' };
    manager.addMove(newMove);

    expect(manager.getAllMoves()).toHaveLength(2);
    expect(manager.getAllMoves()[1].to).toBe('d4');
  });

  test('should format moves correctly', () => {
    const moves = [
      { from: 'e2', to: 'e4', piece: 'P' },
      { from: 'e7', to: 'e5', piece: 'p' },
      { from: 'g1', to: 'f3', piece: 'N' },
      { from: 'b8', to: 'c6', piece: 'n' }
    ];

    moves.forEach(move => manager.addMove(move));

    const formatted = manager.getFormattedMoves();
    expect(formatted).toHaveLength(2);
    expect(formatted[0].text).toContain('1. e4 e5');
    expect(formatted[1].text).toContain('2. Nf3 Nc6');
  });

  test('should export to PGN format', () => {
    const moves = [
      { from: 'e2', to: 'e4', piece: 'P' },
      { from: 'e7', to: 'e5', piece: 'p' }
    ];

    moves.forEach(move => manager.addMove(move));

    const pgn = manager.toPGN({
      white: 'Player 1',
      black: 'Player 2',
      result: '1-0'
    });

    expect(pgn).toContain('[White "Player 1"]');
    expect(pgn).toContain('[Black "Player 2"]');
    expect(pgn).toContain('1. e4 e5');
    expect(pgn).toContain('1-0');
  });
});

Integration Tests

// shared/__tests__/move-history-integration.test.js
import { MoveHistoryManager } from '../services/MoveHistoryManager';
import { ChessGameEngine } from '../chess-engine/GameEngine';

describe('Move History Integration', () => {
  let gameEngine;
  let historyManager;

  beforeEach(() => {
    gameEngine = new ChessGameEngine();
    historyManager = new MoveHistoryManager();
  });

  test('should integrate with game engine', () => {
    const moves = ['e2-e4', 'e7-e5', 'g1-f3', 'b8-c6'];

    moves.forEach(moveStr => {
      const [from, to] = moveStr.split('-');
      const result = gameEngine.makeMove(from, to);

      expect(result.success).toBe(true);
      historyManager.addMove(result.move);
    });

    expect(historyManager.getAllMoves()).toHaveLength(4);
    expect(gameEngine.currentPlayer).toBe('white');
  });

  test('should replay moves correctly', () => {
    // Play a sequence of moves
    const moves = ['e2-e4', 'e7-e5', 'f1-c4', 'd7-d6'];

    moves.forEach(moveStr => {
      const [from, to] = moveStr.split('-');
      const result = gameEngine.makeMove(from, to);
      historyManager.addMove(result.move);
    });

    // Reset game and replay from history
    gameEngine.reset();
    const historyMoves = historyManager.getAllMoves();

    historyMoves.forEach(move => {
      const result = gameEngine.makeMove(move.from, move.to);
      expect(result.success).toBe(true);
    });

    expect(gameEngine.currentPlayer).toBe('white');
  });
});

Performance Optimization

Virtual Scrolling for Large Games

// shared/components/VirtualMoveList.js
export class VirtualMoveList {
  constructor(container, itemHeight = 30) {
    this.container = container;
    this.itemHeight = itemHeight;
    this.visibleStart = 0;
    this.visibleEnd = 0;
    this.scrollTop = 0;
    this.moves = [];

    this.setupScrolling();
  }

  setupScrolling() {
    this.container.addEventListener('scroll', () => {
      this.scrollTop = this.container.scrollTop;
      this.updateVisibleRange();
      this.renderVisibleItems();
    });
  }

  updateMoves(moves) {
    this.moves = moves;
    this.updateVisibleRange();
    this.renderVisibleItems();
  }

  updateVisibleRange() {
    const containerHeight = this.container.clientHeight;
    const totalHeight = this.moves.length * this.itemHeight;

    this.visibleStart = Math.floor(this.scrollTop / this.itemHeight);
    this.visibleEnd = Math.min(
      this.moves.length,
      this.visibleStart + Math.ceil(containerHeight / this.itemHeight) + 1
    );

    // Update container height
    this.container.style.height = `${totalHeight}px`;
  }

  renderVisibleItems() {
    // Clear existing items
    this.container.innerHTML = '';

    // Create spacer for items before visible range
    if (this.visibleStart > 0) {
      const spacer = document.createElement('div');
      spacer.style.height = `${this.visibleStart * this.itemHeight}px`;
      this.container.appendChild(spacer);
    }

    // Render visible items
    for (let i = this.visibleStart; i < this.visibleEnd; i++) {
      const item = this.createMoveItem(this.moves[i], i);
      this.container.appendChild(item);
    }
  }

  createMoveItem(move, index) {
    const item = document.createElement('div');
    item.className = 'move-item';
    item.style.height = `${this.itemHeight}px`;
    item.textContent = move.toAlgebraic();
    item.dataset.index = index;
    return item;
  }
}

Next Steps

⚠️ **GitHub.com Fallback** ⚠️