From fee8f09dacbfd66154f28ca18fe1892f5678733c Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Sat, 26 Jul 2025 17:22:59 -0400 Subject: [PATCH 1/5] feat: add initial iteration of "Learn from Mistakes" feature --- .../Analysis/ConfigurableScreens.tsx | 6 + src/components/Analysis/ConfigureAnalysis.tsx | 24 +- src/components/Analysis/LearnFromMistakes.tsx | 142 +++++++++++ src/components/Analysis/index.ts | 1 + src/constants/analysis.ts | 1 + .../useAnalysisController.ts | 238 ++++++++++++++++++ src/lib/analysis/index.ts | 1 + src/lib/analysis/mistakeDetection.ts | 90 +++++++ src/pages/analysis/[...id].tsx | 180 ++++++++++--- src/types/analysis/index.ts | 23 ++ 10 files changed, 676 insertions(+), 30 deletions(-) create mode 100644 src/components/Analysis/LearnFromMistakes.tsx create mode 100644 src/lib/analysis/mistakeDetection.ts diff --git a/src/components/Analysis/ConfigurableScreens.tsx b/src/components/Analysis/ConfigurableScreens.tsx index 85776931..879edc5e 100644 --- a/src/components/Analysis/ConfigurableScreens.tsx +++ b/src/components/Analysis/ConfigurableScreens.tsx @@ -13,7 +13,9 @@ interface Props { currentNode: GameNode onDeleteCustomGame?: () => void onAnalyzeEntireGame?: () => void + onLearnFromMistakes?: () => void isAnalysisInProgress?: boolean + isLearnFromMistakesActive?: boolean autoSave?: { hasUnsavedChanges: boolean isSaving: boolean @@ -30,7 +32,9 @@ export const ConfigurableScreens: React.FC = ({ currentNode, onDeleteCustomGame, onAnalyzeEntireGame, + onLearnFromMistakes, isAnalysisInProgress, + isLearnFromMistakesActive, autoSave, }) => { const screens = [ @@ -87,7 +91,9 @@ export const ConfigurableScreens: React.FC = ({ game={game} onDeleteCustomGame={onDeleteCustomGame} onAnalyzeEntireGame={onAnalyzeEntireGame} + onLearnFromMistakes={onLearnFromMistakes} isAnalysisInProgress={isAnalysisInProgress} + isLearnFromMistakesActive={isLearnFromMistakesActive} autoSave={autoSave} /> ) : screen.id === 'export' ? ( diff --git a/src/components/Analysis/ConfigureAnalysis.tsx b/src/components/Analysis/ConfigureAnalysis.tsx index fe7b6e88..601137bb 100644 --- a/src/components/Analysis/ConfigureAnalysis.tsx +++ b/src/components/Analysis/ConfigureAnalysis.tsx @@ -11,7 +11,9 @@ interface Props { game: AnalyzedGame onDeleteCustomGame?: () => void onAnalyzeEntireGame?: () => void + onLearnFromMistakes?: () => void isAnalysisInProgress?: boolean + isLearnFromMistakesActive?: boolean autoSave?: { hasUnsavedChanges: boolean isSaving: boolean @@ -27,7 +29,9 @@ export const ConfigureAnalysis: React.FC = ({ game, onDeleteCustomGame, onAnalyzeEntireGame, + onLearnFromMistakes, isAnalysisInProgress = false, + isLearnFromMistakesActive = false, autoSave, }: Props) => { const isCustomGame = game.type === 'custom-pgn' || game.type === 'custom-fen' @@ -55,8 +59,8 @@ export const ConfigureAnalysis: React.FC = ({ {onAnalyzeEntireGame && ( )} + {onLearnFromMistakes && ( + + )} {autoSave && game.type !== 'custom-pgn' && game.type !== 'custom-fen' && diff --git a/src/components/Analysis/LearnFromMistakes.tsx b/src/components/Analysis/LearnFromMistakes.tsx new file mode 100644 index 00000000..ffd0d9ab --- /dev/null +++ b/src/components/Analysis/LearnFromMistakes.tsx @@ -0,0 +1,142 @@ +import React from 'react' +import { LearnFromMistakesState, MistakePosition } from 'src/types/analysis' + +interface Props { + state: LearnFromMistakesState + currentInfo: { + mistake: MistakePosition + progress: string + isLastMistake: boolean + } | null + onShowSolution: () => void + onNext: () => void + onStop: () => void + lastMoveResult?: 'correct' | 'incorrect' | 'not-learning' +} + +export const LearnFromMistakes: React.FC = ({ + state, + currentInfo, + onShowSolution, + onNext, + onStop, + lastMoveResult, +}) => { + if (!state.isActive || !currentInfo) { + return null + } + + const { mistake, progress, isLastMistake } = currentInfo + + const getMoveDisplay = () => { + const moveNumber = Math.ceil((mistake.moveIndex + 1) / 2) + const isWhiteMove = mistake.moveIndex % 2 === 0 + + if (isWhiteMove) { + return `${moveNumber}. ${mistake.san}` + } else { + return `${moveNumber}... ${mistake.san}` + } + } + + const getPromptText = () => { + const mistakeType = mistake.type === 'blunder' ? '??' : '?!' + const moveDisplay = getMoveDisplay() + const playerColorName = mistake.playerColor === 'white' ? 'White' : 'Black' + + return `${moveDisplay}${mistakeType} was played. Find a better move for ${playerColorName}.` + } + + const getFeedbackText = () => { + if (state.showSolution) { + return `Correct! ${mistake.bestMoveSan} was the best move.` + } + + if (lastMoveResult === 'incorrect') { + const playerColorName = + mistake.playerColor === 'white' ? 'White' : 'Black' + return `You can do better. Try another move for ${playerColorName}.` + } + + return null + } + + return ( +
+
+ {/* Header */} +
+
+ + school + +

Learn from Mistakes

+ ({progress}) +
+ +
+ + {/* Main prompt */} +
+

{getPromptText()}

+ {getFeedbackText() && ( +

+ {getFeedbackText()} +

+ )} +
+ + {/* Action buttons */} +
+ {!state.showSolution ? ( + + ) : ( + + )} + + {state.showSolution && !isLastMistake && ( + + )} +
+
+
+ ) +} diff --git a/src/components/Analysis/index.ts b/src/components/Analysis/index.ts index 4f68a750..fc650a15 100644 --- a/src/components/Analysis/index.ts +++ b/src/components/Analysis/index.ts @@ -12,3 +12,4 @@ export * from './AnalysisNotification' export * from './AnalysisOverlay' export * from './InteractiveDescription' export * from './AnalysisSidebar' +export * from './LearnFromMistakes' diff --git a/src/constants/analysis.ts b/src/constants/analysis.ts index 95aa4d48..7b45d84b 100644 --- a/src/constants/analysis.ts +++ b/src/constants/analysis.ts @@ -19,6 +19,7 @@ export const MOVE_CLASSIFICATION_THRESHOLDS = { export const DEFAULT_MAIA_MODEL = 'maia_kdd_1500' as const export const MIN_STOCKFISH_DEPTH = 12 as const +export const LEARN_FROM_MISTAKES_DEPTH = 15 as const export const COLORS = { good: ['#238b45', '#41ab5d', '#74c476', '#90D289', '#AEDFA4'], diff --git a/src/hooks/useAnalysisController/useAnalysisController.ts b/src/hooks/useAnalysisController/useAnalysisController.ts index 2817ba53..f83cb17c 100644 --- a/src/hooks/useAnalysisController/useAnalysisController.ts +++ b/src/hooks/useAnalysisController/useAnalysisController.ts @@ -24,6 +24,9 @@ import { generateAnalysisCacheKey, } from 'src/lib/analysisStorage' import { storeEngineAnalysis } from 'src/api/analysis/analysis' +import { extractPlayerMistakes, isBestMove } from 'src/lib/analysis' +import { LearnFromMistakesState, MistakePosition } from 'src/types/analysis' +import { LEARN_FROM_MISTAKES_DEPTH } from 'src/constants/analysis' export interface GameAnalysisProgress { currentMoveIndex: number @@ -313,6 +316,231 @@ export const useAnalysisController = ( }) }, []) + // Learn from mistakes state + const [learnFromMistakesState, setLearnFromMistakesState] = + useState({ + isActive: false, + currentMistakeIndex: 0, + mistakes: [], + hasCompletedAnalysis: false, + showSolution: false, + currentAttempt: 1, + maxAttempts: Infinity, // Infinite attempts + originalPosition: null, + }) + + // Learn from mistakes functions + const startLearnFromMistakes = useCallback(async () => { + // First, ensure the entire game is analyzed at the required depth + if (!gameAnalysisProgress.isComplete) { + // Start analysis first + await startGameAnalysis(LEARN_FROM_MISTAKES_DEPTH) + + // Wait for analysis to complete + return new Promise((resolve) => { + const checkComplete = () => { + if ( + gameAnalysisProgress.isComplete || + gameAnalysisProgress.isCancelled + ) { + if (gameAnalysisProgress.isComplete) { + initializeLearnFromMistakes() + } + resolve() + } else { + setTimeout(checkComplete, 500) + } + } + checkComplete() + }) + } else { + initializeLearnFromMistakes() + } + }, [gameAnalysisProgress, startGameAnalysis]) + + const initializeLearnFromMistakes = useCallback(() => { + // Determine which player to analyze based on the current user + // For now, we'll analyze the user playing as white by default + // This could be enhanced to detect which player is the user + const playerColor: 'white' | 'black' = 'white' // This could be made configurable + + const mistakes = extractPlayerMistakes(game.tree, playerColor) + + if (mistakes.length === 0) { + // No mistakes found - could show a message + return + } + + // Navigate to the first mistake position (the position where the player needs to move) + const firstMistake = mistakes[0] + const mistakeNode = game.tree.getMainLine()[firstMistake.moveIndex] + const originalPosition = + mistakeNode && mistakeNode.parent ? mistakeNode.parent.fen : null + + setLearnFromMistakesState({ + isActive: true, + currentMistakeIndex: 0, + mistakes, + hasCompletedAnalysis: true, + showSolution: false, + currentAttempt: 1, + maxAttempts: Infinity, + originalPosition, + }) + + if (mistakeNode && mistakeNode.parent) { + controller.setCurrentNode(mistakeNode.parent) + } + }, [game.tree, controller]) + + const stopLearnFromMistakes = useCallback(() => { + setLearnFromMistakesState({ + isActive: false, + currentMistakeIndex: 0, + mistakes: [], + hasCompletedAnalysis: false, + showSolution: false, + currentAttempt: 1, + maxAttempts: Infinity, + originalPosition: null, + }) + }, []) + + const showSolution = useCallback(() => { + if ( + !learnFromMistakesState.isActive || + learnFromMistakesState.mistakes.length === 0 + ) + return + + const currentMistake = + learnFromMistakesState.mistakes[ + learnFromMistakesState.currentMistakeIndex + ] + if (!currentMistake || !controller.currentNode) return + + // Make the best move on the board (this will create a variation) + const chess = new Chess(controller.currentNode.fen) + const moveResult = chess.move(currentMistake.bestMove, { sloppy: true }) + + if (moveResult) { + const newVariation = game.tree.addVariation( + controller.currentNode, + chess.fen(), + currentMistake.bestMove, + currentMistake.bestMoveSan, + controller.currentMaiaModel, + ) + controller.goToNode(newVariation) + } + + setLearnFromMistakesState((prev) => ({ + ...prev, + showSolution: true, + })) + }, [learnFromMistakesState, controller, game.tree]) + + const goToNextMistake = useCallback(() => { + if ( + !learnFromMistakesState.isActive || + learnFromMistakesState.mistakes.length === 0 + ) + return + + const nextIndex = learnFromMistakesState.currentMistakeIndex + 1 + + if (nextIndex >= learnFromMistakesState.mistakes.length) { + // No more mistakes - end the session + stopLearnFromMistakes() + return + } + + const nextMistake = learnFromMistakesState.mistakes[nextIndex] + const mistakeNode = game.tree.getMainLine()[nextMistake.moveIndex] + const newOriginalPosition = + mistakeNode && mistakeNode.parent ? mistakeNode.parent.fen : null + + if (mistakeNode && mistakeNode.parent) { + controller.setCurrentNode(mistakeNode.parent) + } + + setLearnFromMistakesState((prev) => ({ + ...prev, + currentMistakeIndex: nextIndex, + showSolution: false, + currentAttempt: 1, + originalPosition: newOriginalPosition, + })) + }, [learnFromMistakesState, controller, game.tree, stopLearnFromMistakes]) + + const checkMoveInLearnMode = useCallback( + (moveUci: string): 'correct' | 'incorrect' | 'not-learning' => { + if (!learnFromMistakesState.isActive || !controller.currentNode) + return 'not-learning' + + const currentMistake = + learnFromMistakesState.mistakes[ + learnFromMistakesState.currentMistakeIndex + ] + if (!currentMistake) return 'not-learning' + + const isCorrect = isBestMove(controller.currentNode, moveUci) + + if (isCorrect) { + setLearnFromMistakesState((prev) => ({ + ...prev, + showSolution: true, + })) + return 'correct' + } else { + setLearnFromMistakesState((prev) => ({ + ...prev, + currentAttempt: prev.currentAttempt + 1, + })) + return 'incorrect' + } + }, + [learnFromMistakesState, controller], + ) + + // Function to return to the original position when a move is incorrect + const returnToOriginalPosition = useCallback(() => { + if (!learnFromMistakesState.originalPosition) return + + // Find the node with the original FEN + const mainLine = game.tree.getMainLine() + const originalNode = mainLine.find( + (node) => node.fen === learnFromMistakesState.originalPosition, + ) + + if (originalNode) { + controller.setCurrentNode(originalNode) + } + }, [learnFromMistakesState.originalPosition, game.tree, controller]) + + const getCurrentMistakeInfo = useCallback(() => { + if ( + !learnFromMistakesState.isActive || + learnFromMistakesState.mistakes.length === 0 + ) { + return null + } + + const currentMistake = + learnFromMistakesState.mistakes[ + learnFromMistakesState.currentMistakeIndex + ] + const totalMistakes = learnFromMistakesState.mistakes.length + const currentIndex = learnFromMistakesState.currentMistakeIndex + 1 + + return { + mistake: currentMistake, + progress: `${currentIndex} of ${totalMistakes}`, + isLastMistake: + learnFromMistakesState.currentMistakeIndex === totalMistakes - 1, + } + }, [learnFromMistakesState]) + const [currentMove, setCurrentMove] = useState<[string, string] | null>() const [currentMaiaModel, setCurrentMaiaModel] = useLocalStorage( 'currentMaiaModel', @@ -483,5 +711,15 @@ export const useAnalysisController = ( : ('saved' as const), }, }, + learnFromMistakes: { + state: learnFromMistakesState, + start: startLearnFromMistakes, + stop: stopLearnFromMistakes, + showSolution, + goToNext: goToNextMistake, + checkMove: checkMoveInLearnMode, + getCurrentInfo: getCurrentMistakeInfo, + returnToOriginalPosition, + }, } } diff --git a/src/lib/analysis/index.ts b/src/lib/analysis/index.ts index e4d1e46b..84b1b493 100644 --- a/src/lib/analysis/index.ts +++ b/src/lib/analysis/index.ts @@ -1 +1,2 @@ export * from './positionAnalysis' +export * from './mistakeDetection' diff --git a/src/lib/analysis/mistakeDetection.ts b/src/lib/analysis/mistakeDetection.ts new file mode 100644 index 00000000..fcd1d1cc --- /dev/null +++ b/src/lib/analysis/mistakeDetection.ts @@ -0,0 +1,90 @@ +import { Chess } from 'chess.ts' +import { GameNode, GameTree } from 'src/types/base/tree' +import { MistakePosition } from 'src/types/analysis' + +/** + * Extracts all mistakes (blunders and inaccuracies) from the game tree for a specific player + */ +export function extractPlayerMistakes( + gameTree: GameTree, + playerColor: 'white' | 'black', +): MistakePosition[] { + const mainLine = gameTree.getMainLine() + const mistakes: MistakePosition[] = [] + + // Skip the root node (starting position) + for (let i = 1; i < mainLine.length; i++) { + const node = mainLine[i] + + // Check if this move was made by the specified player + const isPlayerMove = node.turn === (playerColor === 'white' ? 'b' : 'w') // opposite because turn indicates who moves next + + if ( + isPlayerMove && + (node.blunder || node.inaccuracy) && + node.move && + node.san + ) { + const parentNode = node.parent + if (!parentNode) continue + + // Get the best move from the parent node's Stockfish analysis + const stockfishEval = parentNode.analysis.stockfish + if (!stockfishEval || !stockfishEval.model_move) continue + + // Convert the best move to SAN notation + const chess = new Chess(parentNode.fen) + const bestMoveResult = chess.move(stockfishEval.model_move, { + sloppy: true, + }) + if (!bestMoveResult) continue + + mistakes.push({ + nodeId: `move-${i}`, // Simple ID based on position in main line + moveIndex: i, // Index of the mistake node in the main line + fen: parentNode.fen, // Position before the mistake + playedMove: node.move, + san: node.san, + type: node.blunder ? 'blunder' : 'inaccuracy', + bestMove: stockfishEval.model_move, + bestMoveSan: bestMoveResult.san, + playerColor, + }) + } + } + + return mistakes +} + +/** + * Gets the best move for a given position from the node's Stockfish analysis + */ +export function getBestMoveForPosition(node: GameNode): { + move: string + san: string +} | null { + const stockfishEval = node.analysis.stockfish + if (!stockfishEval || !stockfishEval.model_move) { + return null + } + + const chess = new Chess(node.fen) + const moveResult = chess.move(stockfishEval.model_move, { sloppy: true }) + + if (!moveResult) { + return null + } + + return { + move: stockfishEval.model_move, + san: moveResult.san, + } +} + +/** + * Checks if a move matches the best move for a position + */ +export function isBestMove(node: GameNode, moveUci: string): boolean { + const bestMove = getBestMoveForPosition(node) + return bestMove ? bestMove.move === moveUci : false +} diff --git a/src/pages/analysis/[...id].tsx b/src/pages/analysis/[...id].tsx index 1fb024a5..4ce1404e 100644 --- a/src/pages/analysis/[...id].tsx +++ b/src/pages/analysis/[...id].tsx @@ -39,6 +39,7 @@ import { ConfigurableScreens } from 'src/components/Analysis/ConfigurableScreens import { AnalysisConfigModal } from 'src/components/Analysis/AnalysisConfigModal' import { AnalysisNotification } from 'src/components/Analysis/AnalysisNotification' import { AnalysisOverlay } from 'src/components/Analysis/AnalysisOverlay' +import { LearnFromMistakes } from 'src/components/Analysis/LearnFromMistakes' import { GameBoard } from 'src/components/Board/GameBoard' import { MovesContainer } from 'src/components/Board/MovesContainer' import { BoardController } from 'src/components/Board/BoardController' @@ -379,6 +380,9 @@ const Analysis: React.FC = ({ const [showAnalysisConfigModal, setShowAnalysisConfigModal] = useState(false) const [refreshTrigger, setRefreshTrigger] = useState(0) const [analysisEnabled, setAnalysisEnabled] = useState(true) // Analysis enabled by default + const [lastMoveResult, setLastMoveResult] = useState< + 'correct' | 'incorrect' | 'not-learning' + >('not-learning') const controller = useAnalysisController(analyzedGame) @@ -448,6 +452,24 @@ const Analysis: React.FC = ({ setAnalysisEnabled((prev) => !prev) }, []) + const handleLearnFromMistakes = useCallback(() => { + controller.learnFromMistakes.start() + }, [controller.learnFromMistakes]) + + const handleStopLearnFromMistakes = useCallback(() => { + controller.learnFromMistakes.stop() + setLastMoveResult('not-learning') + }, [controller.learnFromMistakes]) + + const handleShowSolution = useCallback(() => { + controller.learnFromMistakes.showSolution() + }, [controller.learnFromMistakes]) + + const handleNextMistake = useCallback(() => { + controller.learnFromMistakes.goToNext() + setLastMoveResult('not-learning') + }, [controller.learnFromMistakes]) + // Create empty data structures for when analysis is disabled const emptyBlunderMeterData = useMemo( () => ({ @@ -494,6 +516,10 @@ const Analysis: React.FC = ({ if (!analysisEnabled || !controller.currentNode || !analyzedGame.tree) return + // Check if we're in learn from mistakes mode + const learnResult = controller.learnFromMistakes.checkMove(move) + setLastMoveResult(learnResult) + const chess = new Chess(controller.currentNode.fen) const moveAttempt = chess.move({ from: move.slice(0, 2), @@ -510,6 +536,15 @@ const Analysis: React.FC = ({ (moveAttempt.promotion ? moveAttempt.promotion : '') const san = moveAttempt.san + // In learn from mistakes mode, if the move is incorrect, don't actually make it and return to original position + if (learnResult === 'incorrect') { + // Return to the original position after a brief delay so the user can see what happened + setTimeout(() => { + controller.learnFromMistakes.returnToOriginalPosition() + }, 100) + return + } + if (controller.currentNode.mainChild?.move === moveString) { // Existing main line move - navigate to it controller.goToNode(controller.currentNode.mainChild) @@ -828,7 +863,11 @@ const Analysis: React.FC = ({ currentNode={controller.currentNode as GameNode} onDeleteCustomGame={handleDeleteCustomGame} onAnalyzeEntireGame={handleAnalyzeEntireGame} + onLearnFromMistakes={handleLearnFromMistakes} isAnalysisInProgress={controller.gameAnalysis.progress.isAnalyzing} + isLearnFromMistakesActive={ + controller.learnFromMistakes.state.isActive + } autoSave={controller.gameAnalysis.autoSave} /> @@ -837,7 +876,9 @@ const Analysis: React.FC = ({ makeMove={makeMove} controller={controller} setHoverArrow={setHoverArrow} - analysisEnabled={analysisEnabled} + analysisEnabled={ + analysisEnabled && !controller.learnFromMistakes.state.isActive + } handleToggleAnalysis={handleToggleAnalysis} itemVariants={itemVariants} /> @@ -1007,17 +1048,29 @@ const Analysis: React.FC = ({
= ({ } } colorSanMapping={ - analysisEnabled ? controller.colorSanMapping : {} + analysisEnabled && + !controller.learnFromMistakes.state.isActive + ? controller.colorSanMapping + : {} } boardDescription={ - analysisEnabled + analysisEnabled && + !controller.learnFromMistakes.state.isActive ? controller.boardDescription : { segments: [ { type: 'text', - content: - 'Analysis is disabled. Enable analysis to see detailed move evaluations and recommendations.', + content: controller.learnFromMistakes.state + .isActive + ? 'Analysis is hidden during Learn from Mistakes mode.' + : 'Analysis is disabled. Enable analysis to see detailed move evaluations and recommendations.', }, ], } } currentNode={controller.currentNode} /> - {!analysisEnabled && ( + {(!analysisEnabled || + controller.learnFromMistakes.state.isActive) && (
lock

- Analysis Disabled + {controller.learnFromMistakes.state.isActive + ? 'Learning Mode Active' + : 'Analysis Disabled'}

@@ -1061,28 +1123,48 @@ const Analysis: React.FC = ({
- {!analysisEnabled && ( + {(!analysisEnabled || + controller.learnFromMistakes.state.isActive) && (
lock

- Analysis Disabled + {controller.learnFromMistakes.state.isActive + ? 'Learning Mode Active' + : 'Analysis Disabled'}

@@ -1091,19 +1173,30 @@ const Analysis: React.FC = ({
- {!analysisEnabled && ( + {(!analysisEnabled || + controller.learnFromMistakes.state.isActive) && (
lock

- Analysis Disabled + {controller.learnFromMistakes.state.isActive + ? 'Learning Mode Active' + : 'Analysis Disabled'}

@@ -1112,23 +1205,42 @@ const Analysis: React.FC = ({
- {!analysisEnabled && ( + {(!analysisEnabled || + controller.learnFromMistakes.state.isActive) && (
lock

- Analysis Disabled + {controller.learnFromMistakes.state.isActive + ? 'Learning Mode Active' + : 'Analysis Disabled'}

@@ -1143,9 +1255,13 @@ const Analysis: React.FC = ({ game={analyzedGame} onDeleteCustomGame={handleDeleteCustomGame} onAnalyzeEntireGame={handleAnalyzeEntireGame} + onLearnFromMistakes={handleLearnFromMistakes} isAnalysisInProgress={ controller.gameAnalysis.progress.isAnalyzing } + isLearnFromMistakesActive={ + controller.learnFromMistakes.state.isActive + } autoSave={controller.gameAnalysis.autoSave} currentNode={controller.currentNode as GameNode} /> @@ -1221,6 +1337,14 @@ const Analysis: React.FC = ({ )} + ) } diff --git a/src/types/analysis/index.ts b/src/types/analysis/index.ts index 101df85c..87dba987 100644 --- a/src/types/analysis/index.ts +++ b/src/types/analysis/index.ts @@ -141,3 +141,26 @@ export type MaiaStatus = | 'error' export type StockfishStatus = 'loading' | 'ready' | 'error' + +export interface MistakePosition { + nodeId: string + moveIndex: number + fen: string + playedMove: string + san: string + type: 'blunder' | 'inaccuracy' + bestMove: string + bestMoveSan: string + playerColor: 'white' | 'black' +} + +export interface LearnFromMistakesState { + isActive: boolean + currentMistakeIndex: number + mistakes: MistakePosition[] + hasCompletedAnalysis: boolean + showSolution: boolean + currentAttempt: number + maxAttempts: number + originalPosition: string | null // FEN of the position where the player should make a move +} From 45907bbc04e2335f685203dccef1b3d6c9721884 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Sat, 26 Jul 2025 17:53:12 -0400 Subject: [PATCH 2/5] feat: improved learn from mistake behaviour + ui under game board --- .../Analysis/ConfigurableScreens.tsx | 63 +++++++++++++++++- src/components/Analysis/LearnFromMistakes.tsx | 59 +++++++++-------- src/components/Board/MovesContainer.tsx | 66 ++++++++++++++----- src/pages/analysis/[...id].tsx | 62 ++++++++++++----- 4 files changed, 188 insertions(+), 62 deletions(-) diff --git a/src/components/Analysis/ConfigurableScreens.tsx b/src/components/Analysis/ConfigurableScreens.tsx index 879edc5e..d8e21d05 100644 --- a/src/components/Analysis/ConfigurableScreens.tsx +++ b/src/components/Analysis/ConfigurableScreens.tsx @@ -1,8 +1,14 @@ import { motion } from 'framer-motion' import React, { useState } from 'react' import { ConfigureAnalysis } from 'src/components/Analysis/ConfigureAnalysis' +import { LearnFromMistakes } from 'src/components/Analysis/LearnFromMistakes' import { ExportGame } from 'src/components/Common/ExportGame' -import { AnalyzedGame, GameNode } from 'src/types' +import { + AnalyzedGame, + GameNode, + LearnFromMistakesState, + MistakePosition, +} from 'src/types' interface Props { currentMaiaModel: string @@ -21,6 +27,17 @@ interface Props { isSaving: boolean status: 'saving' | 'unsaved' | 'saved' } + // Learn from mistakes props + learnFromMistakesState?: LearnFromMistakesState + learnFromMistakesCurrentInfo?: { + mistake: MistakePosition + progress: string + isLastMistake: boolean + } | null + onShowSolution?: () => void + onNextMistake?: () => void + onStopLearnFromMistakes?: () => void + lastMoveResult?: 'correct' | 'incorrect' | 'not-learning' } export const ConfigurableScreens: React.FC = ({ @@ -36,6 +53,12 @@ export const ConfigurableScreens: React.FC = ({ isAnalysisInProgress, isLearnFromMistakesActive, autoSave, + learnFromMistakesState, + learnFromMistakesCurrentInfo, + onShowSolution, + onNextMistake, + onStopLearnFromMistakes, + lastMoveResult, }) => { const screens = [ { @@ -50,6 +73,44 @@ export const ConfigurableScreens: React.FC = ({ const [screen, setScreen] = useState(screens[0]) + // If learn from mistakes is active, show only the learning interface + if ( + isLearnFromMistakesActive && + learnFromMistakesState && + learnFromMistakesCurrentInfo + ) { + return ( +
+
+ { + /* no-op */ + }) + } + onNext={ + onNextMistake || + (() => { + /* no-op */ + }) + } + onStop={ + onStopLearnFromMistakes || + (() => { + /* no-op */ + }) + } + lastMoveResult={lastMoveResult || 'not-learning'} + /> +
+
+ ) + } + + // Normal state with configure/export tabs return (
diff --git a/src/components/Analysis/LearnFromMistakes.tsx b/src/components/Analysis/LearnFromMistakes.tsx index ffd0d9ab..476109f1 100644 --- a/src/components/Analysis/LearnFromMistakes.tsx +++ b/src/components/Analysis/LearnFromMistakes.tsx @@ -29,8 +29,8 @@ export const LearnFromMistakes: React.FC = ({ const { mistake, progress, isLastMistake } = currentInfo const getMoveDisplay = () => { - const moveNumber = Math.ceil((mistake.moveIndex + 1) / 2) - const isWhiteMove = mistake.moveIndex % 2 === 0 + const moveNumber = Math.ceil(mistake.moveIndex / 2) + const isWhiteMove = mistake.playerColor === 'white' if (isWhiteMove) { return `${moveNumber}. ${mistake.san}` @@ -49,7 +49,11 @@ export const LearnFromMistakes: React.FC = ({ const getFeedbackText = () => { if (state.showSolution) { - return `Correct! ${mistake.bestMoveSan} was the best move.` + if (lastMoveResult === 'correct') { + return `Correct! ${mistake.bestMoveSan} was the best move.` + } else { + return `The best move was ${mistake.bestMoveSan}.` + } } if (lastMoveResult === 'incorrect') { @@ -62,7 +66,7 @@ export const LearnFromMistakes: React.FC = ({ } return ( -
+
{/* Header */}
@@ -83,7 +87,7 @@ export const LearnFromMistakes: React.FC = ({
{/* Main prompt */} -
+

{getPromptText()}

{getFeedbackText() && (

= ({ {/* Action buttons */}

- {!state.showSolution ? ( - + {!state.showSolution && lastMoveResult !== 'correct' ? ( + <> + + {!isLastMistake && ( + + )} + ) : ( )} - - {state.showSolution && !isLastMistake && ( - - )}
diff --git a/src/components/Board/MovesContainer.tsx b/src/components/Board/MovesContainer.tsx index d60f69b1..2cd8f2b5 100644 --- a/src/components/Board/MovesContainer.tsx +++ b/src/components/Board/MovesContainer.tsx @@ -15,6 +15,7 @@ interface AnalysisProps { showAnnotations?: boolean showVariations?: boolean disableKeyboardNavigation?: boolean + disableMoveClicking?: boolean } interface TuringProps { @@ -25,6 +26,7 @@ interface TuringProps { showAnnotations?: boolean showVariations?: boolean disableKeyboardNavigation?: boolean + disableMoveClicking?: boolean } interface PlayProps { @@ -35,6 +37,7 @@ interface PlayProps { showAnnotations?: boolean showVariations?: boolean disableKeyboardNavigation?: boolean + disableMoveClicking?: boolean } type Props = AnalysisProps | TuringProps | PlayProps @@ -70,6 +73,7 @@ export const MovesContainer: React.FC = (props) => { showAnnotations = true, showVariations = true, disableKeyboardNavigation = false, + disableMoveClicking = false, } = props const { isMobile } = useContext(WindowSizeContext) const containerRef = useRef(null) @@ -224,9 +228,11 @@ export const MovesContainer: React.FC = (props) => { ? currentMoveRef : null } - onClick={() => - baseController.goToNode(pair.whiteMove as GameNode) - } + onClick={() => { + if (!disableMoveClicking) { + baseController.goToNode(pair.whiteMove as GameNode) + } + }} className={`flex min-w-fit cursor-pointer flex-row items-center rounded px-2 py-1 text-sm ${ baseController.currentNode === pair.whiteMove ? 'bg-human-4/20' @@ -262,9 +268,11 @@ export const MovesContainer: React.FC = (props) => { ? currentMoveRef : null } - onClick={() => - baseController.goToNode(pair.blackMove as GameNode) - } + onClick={() => { + if (!disableMoveClicking) { + baseController.goToNode(pair.blackMove as GameNode) + } + }} className={`flex min-w-fit cursor-pointer flex-row items-center rounded px-2 py-1 text-sm ${ baseController.currentNode === pair.blackMove ? 'bg-human-4/20' @@ -298,9 +306,13 @@ export const MovesContainer: React.FC = (props) => { {termination && (
- baseController.goToNode(mainLineNodes[mainLineNodes.length - 1]) - } + onClick={() => { + if (!disableMoveClicking) { + baseController.goToNode( + mainLineNodes[mainLineNodes.length - 1], + ) + } + }} > {termination.result} {', '} @@ -330,7 +342,9 @@ export const MovesContainer: React.FC = (props) => { baseController.currentNode === whiteNode ? currentMoveRef : null } onClick={() => { - if (whiteNode) baseController.goToNode(whiteNode) + if (whiteNode && !disableMoveClicking) { + baseController.goToNode(whiteNode) + } }} data-index={index * 2 + 1} className={`col-span-2 flex h-7 flex-1 cursor-pointer flex-row items-center justify-between px-2 text-sm hover:bg-background-2 ${baseController.currentNode === whiteNode && 'bg-human-4/10'} ${highlightSet.has(index * 2 + 1) && 'bg-human-3/80'}`} @@ -363,6 +377,7 @@ export const MovesContainer: React.FC = (props) => { currentNode={baseController.currentNode} goToNode={baseController.goToNode} showAnnotations={showAnnotations} + disableMoveClicking={disableMoveClicking} /> ) : null}
= (props) => { baseController.currentNode === blackNode ? currentMoveRef : null } onClick={() => { - if (blackNode) baseController.goToNode(blackNode) + if (blackNode && !disableMoveClicking) { + baseController.goToNode(blackNode) + } }} data-index={index * 2 + 2} className={`col-span-2 flex h-7 flex-1 cursor-pointer flex-row items-center justify-between px-2 text-sm hover:bg-background-2 ${baseController.currentNode === blackNode && 'bg-human-4/10'} ${highlightSet.has(index * 2 + 2) && 'bg-human-3/80'}`} @@ -403,6 +420,7 @@ export const MovesContainer: React.FC = (props) => { currentNode={baseController.currentNode} goToNode={baseController.goToNode} showAnnotations={showAnnotations} + disableMoveClicking={disableMoveClicking} /> ) : null} @@ -411,9 +429,11 @@ export const MovesContainer: React.FC = (props) => { {termination && !isMobile && (
- baseController.goToNode(mainLineNodes[mainLineNodes.length - 1]) - } + onClick={() => { + if (!disableMoveClicking) { + baseController.goToNode(mainLineNodes[mainLineNodes.length - 1]) + } + }} > {termination.result} {', '} @@ -432,12 +452,14 @@ function FirstVariation({ currentNode, goToNode, showAnnotations, + disableMoveClicking = false, }: { node: GameNode color: 'white' | 'black' currentNode: GameNode | undefined goToNode: (node: GameNode) => void showAnnotations: boolean + disableMoveClicking?: boolean }) { return ( <> @@ -452,6 +474,7 @@ function FirstVariation({ goToNode={goToNode} level={0} showAnnotations={showAnnotations} + disableMoveClicking={disableMoveClicking} /> ))} @@ -487,7 +510,11 @@ function VariationTree({ return (
  • goToNode(node)} + onClick={() => { + if (!disableMoveClicking) { + goToNode(node) + } + }} className={`cursor-pointer px-0.5 text-xs ${ currentNode?.fen === node?.fen ? 'rounded bg-human-4/20 text-primary' @@ -520,6 +547,7 @@ function VariationTree({ goToNode={goToNode} level={level} showAnnotations={showAnnotations} + disableMoveClicking={disableMoveClicking} /> ) : variations.length > 1 ? ( @@ -532,6 +560,7 @@ function VariationTree({ goToNode={goToNode} level={level + 1} showAnnotations={showAnnotations} + disableMoveClicking={disableMoveClicking} /> ))} @@ -577,7 +606,11 @@ function InlineChain({ {chain.map((child) => ( goToNode(child)} + onClick={() => { + if (!disableMoveClicking) { + goToNode(child) + } + }} className={`cursor-pointer px-0.5 text-xs ${ currentNode?.fen === child?.fen ? 'rounded bg-human-4/50 text-primary' @@ -617,6 +650,7 @@ function InlineChain({ goToNode={goToNode} level={level + 1} showAnnotations={showAnnotations} + disableMoveClicking={disableMoveClicking} /> ))} diff --git a/src/pages/analysis/[...id].tsx b/src/pages/analysis/[...id].tsx index 4ce1404e..c6639e59 100644 --- a/src/pages/analysis/[...id].tsx +++ b/src/pages/analysis/[...id].tsx @@ -454,6 +454,7 @@ const Analysis: React.FC = ({ const handleLearnFromMistakes = useCallback(() => { controller.learnFromMistakes.start() + setAnalysisEnabled(false) // Auto-disable analysis when starting learn mode }, [controller.learnFromMistakes]) const handleStopLearnFromMistakes = useCallback(() => { @@ -463,11 +464,13 @@ const Analysis: React.FC = ({ const handleShowSolution = useCallback(() => { controller.learnFromMistakes.showSolution() + setAnalysisEnabled(true) // Auto-enable analysis when showing solution }, [controller.learnFromMistakes]) const handleNextMistake = useCallback(() => { controller.learnFromMistakes.goToNext() setLastMoveResult('not-learning') + setAnalysisEnabled(false) // Auto-disable analysis when going to next mistake }, [controller.learnFromMistakes]) // Create empty data structures for when analysis is disabled @@ -513,13 +516,15 @@ const Analysis: React.FC = ({ }, []) const makeMove = (move: string) => { - if (!analysisEnabled || !controller.currentNode || !analyzedGame.tree) - return + if (!controller.currentNode || !analyzedGame.tree) return // Check if we're in learn from mistakes mode const learnResult = controller.learnFromMistakes.checkMove(move) setLastMoveResult(learnResult) + // If analysis is disabled and we're not in learn mode, don't allow moves + if (!analysisEnabled && learnResult === 'not-learning') return + const chess = new Chess(controller.currentNode.fen) const moveAttempt = chess.move({ from: move.slice(0, 2), @@ -538,11 +543,18 @@ const Analysis: React.FC = ({ // In learn from mistakes mode, if the move is incorrect, don't actually make it and return to original position if (learnResult === 'incorrect') { - // Return to the original position after a brief delay so the user can see what happened + // Return to the original position after a half-second delay so the user can process what happened setTimeout(() => { controller.learnFromMistakes.returnToOriginalPosition() - }, 100) + }, 500) + // Clear the incorrect feedback after a longer delay so user can read it + setTimeout(() => { + setLastMoveResult('not-learning') + }, 3000) return + } else if (learnResult === 'correct') { + // Auto-enable analysis when player gets the correct move + setAnalysisEnabled(true) } if (controller.currentNode.mainChild?.move === moveString) { @@ -772,9 +784,13 @@ const Analysis: React.FC = ({ game={analyzedGame} termination={analyzedGame.termination} type="analysis" - showAnnotations={analysisEnabled} + showAnnotations={true} disableKeyboardNavigation={ - controller.gameAnalysis.progress.isAnalyzing + controller.gameAnalysis.progress.isAnalyzing || + controller.learnFromMistakes.state.isActive + } + disableMoveClicking={ + controller.learnFromMistakes.state.isActive } /> = ({ goToPreviousNode={controller.goToPreviousNode} goToRootNode={controller.goToRootNode} disableKeyboardNavigation={ - controller.gameAnalysis.progress.isAnalyzing + controller.gameAnalysis.progress.isAnalyzing || + controller.learnFromMistakes.state.isActive } />
  • @@ -869,6 +886,12 @@ const Analysis: React.FC = ({ controller.learnFromMistakes.state.isActive } autoSave={controller.gameAnalysis.autoSave} + learnFromMistakesState={controller.learnFromMistakes.state} + learnFromMistakesCurrentInfo={controller.learnFromMistakes.getCurrentInfo()} + onShowSolution={handleShowSolution} + onNextMistake={handleNextMistake} + onStopLearnFromMistakes={handleStopLearnFromMistakes} + lastMoveResult={lastMoveResult} /> = ({ goToPreviousNode={controller.goToPreviousNode} goToRootNode={controller.goToRootNode} disableKeyboardNavigation={ - controller.gameAnalysis.progress.isAnalyzing + controller.gameAnalysis.progress.isAnalyzing || + controller.learnFromMistakes.state.isActive } />
    @@ -1015,9 +1039,13 @@ const Analysis: React.FC = ({ game={analyzedGame} termination={analyzedGame.termination} type="analysis" - showAnnotations={analysisEnabled} + showAnnotations={true} disableKeyboardNavigation={ - controller.gameAnalysis.progress.isAnalyzing + controller.gameAnalysis.progress.isAnalyzing || + controller.learnFromMistakes.state.isActive + } + disableMoveClicking={ + controller.learnFromMistakes.state.isActive } />
    @@ -1264,6 +1292,12 @@ const Analysis: React.FC = ({ } autoSave={controller.gameAnalysis.autoSave} currentNode={controller.currentNode as GameNode} + learnFromMistakesState={controller.learnFromMistakes.state} + learnFromMistakesCurrentInfo={controller.learnFromMistakes.getCurrentInfo()} + onShowSolution={handleShowSolution} + onNextMistake={handleNextMistake} + onStopLearnFromMistakes={handleStopLearnFromMistakes} + lastMoveResult={lastMoveResult} /> )} @@ -1337,14 +1371,6 @@ const Analysis: React.FC = ({ )} - ) } From aef56a8a569efad4d1ac317c4ac68c851cefd343 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Sat, 26 Jul 2025 21:35:27 -0400 Subject: [PATCH 3/5] feat: improve flow + fix edge cases --- src/components/Board/BoardController.tsx | 18 ++-- src/components/Board/MovesContainer.tsx | 4 + src/pages/analysis/[...id].tsx | 108 ++++++++++++++++++----- 3 files changed, 101 insertions(+), 29 deletions(-) diff --git a/src/components/Board/BoardController.tsx b/src/components/Board/BoardController.tsx index b2989a08..bc5c5781 100644 --- a/src/components/Board/BoardController.tsx +++ b/src/components/Board/BoardController.tsx @@ -17,6 +17,7 @@ interface Props { disableFlip?: boolean disablePrevious?: boolean disableKeyboardNavigation?: boolean + disableNavigation?: boolean } export const BoardController: React.FC = ({ @@ -33,6 +34,7 @@ export const BoardController: React.FC = ({ disableFlip = false, disablePrevious = false, disableKeyboardNavigation = false, + disableNavigation = false, }: Props) => { const { width } = useWindowSize() @@ -134,29 +136,29 @@ export const BoardController: React.FC = ({ {FlipIcon}
    @@ -834,13 +835,45 @@ const Analysis: React.FC = ({
    { + const baseShapes = [] + + // Add analysis arrows only when analysis is enabled + if (analysisEnabled) { + baseShapes.push(...controller.arrows) + } + + // Add mistake arrow during learn mode when analysis is disabled + if ( + controller.learnFromMistakes.state.isActive && + !analysisEnabled + ) { + const currentInfo = + controller.learnFromMistakes.getCurrentInfo() + if (currentInfo) { + const mistake = currentInfo.mistake + baseShapes.push({ + brush: 'paleGrey', + orig: mistake.playedMove.slice(0, 2) as Key, + dest: mistake.playedMove.slice(2, 4) as Key, + modifiers: { lineWidth: 8 }, + }) + } + } + + // Add hover arrow if present + if (hoverArrow) { + baseShapes.push(hoverArrow) + } + + return baseShapes + })()} currentNode={controller.currentNode as GameNode} orientation={controller.orientation} onPlayerMakeMove={onPlayerMakeMove} @@ -899,9 +932,7 @@ const Analysis: React.FC = ({ makeMove={makeMove} controller={controller} setHoverArrow={setHoverArrow} - analysisEnabled={ - analysisEnabled && !controller.learnFromMistakes.state.isActive - } + analysisEnabled={analysisEnabled} handleToggleAnalysis={handleToggleAnalysis} itemVariants={itemVariants} /> @@ -995,13 +1026,45 @@ const Analysis: React.FC = ({
    { + const baseShapes = [] + + // Add analysis arrows only when analysis is enabled + if (analysisEnabled) { + baseShapes.push(...controller.arrows) + } + + // Add mistake arrow during learn mode when analysis is disabled + if ( + controller.learnFromMistakes.state.isActive && + !analysisEnabled + ) { + const currentInfo = + controller.learnFromMistakes.getCurrentInfo() + if (currentInfo) { + const mistake = currentInfo.mistake + baseShapes.push({ + brush: 'paleGrey', + orig: mistake.playedMove.slice(0, 2) as Key, + dest: mistake.playedMove.slice(2, 4) as Key, + modifiers: { lineWidth: 8 }, + }) + } + } + + // Add hover arrow if present + if (hoverArrow) { + baseShapes.push(hoverArrow) + } + + return baseShapes + })()} currentNode={controller.currentNode as GameNode} orientation={controller.orientation} onPlayerMakeMove={onPlayerMakeMove} @@ -1032,6 +1095,9 @@ const Analysis: React.FC = ({ controller.gameAnalysis.progress.isAnalyzing || controller.learnFromMistakes.state.isActive } + disableNavigation={ + controller.learnFromMistakes.state.isActive + } />
    From 5660d993aef399c6f3215d75d532488d1ff633e0 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Sat, 26 Jul 2025 22:45:36 -0400 Subject: [PATCH 4/5] feat: improve remaining edge cases --- .../Analysis/ConfigurableScreens.tsx | 2 +- src/components/Analysis/LearnFromMistakes.tsx | 81 ++++++++++--------- src/constants/analysis.ts | 2 +- .../useAnalysisController.ts | 75 +++++++++++++++-- 4 files changed, 112 insertions(+), 48 deletions(-) diff --git a/src/components/Analysis/ConfigurableScreens.tsx b/src/components/Analysis/ConfigurableScreens.tsx index d8e21d05..a96ff783 100644 --- a/src/components/Analysis/ConfigurableScreens.tsx +++ b/src/components/Analysis/ConfigurableScreens.tsx @@ -81,7 +81,7 @@ export const ConfigurableScreens: React.FC = ({ ) { return (
    -
    +
    = ({ } return ( -
    -
    - {/* Header */} -
    -
    - - school - -

    Learn from Mistakes

    - ({progress}) +
    +
    +
    +
    +
    + + school + +

    + Learn from your mistakes +

    + ({progress}) +
    +
    - -
    - {/* Main prompt */} -
    -

    {getPromptText()}

    - {getFeedbackText() && ( -

    - {getFeedbackText()} -

    - )} + {/* Main prompt */} +
    +

    {getPromptText()}

    + {getFeedbackText() && ( +

    + {getFeedbackText()} +

    + )} +
    {/* Action buttons */} -
    +
    {!state.showSolution && lastMoveResult !== 'correct' ? ( <> {!isLastMistake && ( )} @@ -134,7 +137,7 @@ export const LearnFromMistakes: React.FC = ({ onClick={onNext} className="flex items-center gap-1.5 rounded bg-human-4/60 px-3 py-1.5 text-sm text-primary/70 transition duration-200 hover:bg-human-4/80 hover:text-primary" > - + {isLastMistake ? 'check' : 'arrow_forward'} {isLastMistake ? 'Finish' : 'Next mistake'} diff --git a/src/constants/analysis.ts b/src/constants/analysis.ts index 7b45d84b..21fc8914 100644 --- a/src/constants/analysis.ts +++ b/src/constants/analysis.ts @@ -19,7 +19,7 @@ export const MOVE_CLASSIFICATION_THRESHOLDS = { export const DEFAULT_MAIA_MODEL = 'maia_kdd_1500' as const export const MIN_STOCKFISH_DEPTH = 12 as const -export const LEARN_FROM_MISTAKES_DEPTH = 15 as const +export const LEARN_FROM_MISTAKES_DEPTH = 12 as const export const COLORS = { good: ['#238b45', '#41ab5d', '#74c476', '#90D289', '#AEDFA4'], diff --git a/src/hooks/useAnalysisController/useAnalysisController.ts b/src/hooks/useAnalysisController/useAnalysisController.ts index f83cb17c..9b326499 100644 --- a/src/hooks/useAnalysisController/useAnalysisController.ts +++ b/src/hooks/useAnalysisController/useAnalysisController.ts @@ -331,9 +331,62 @@ export const useAnalysisController = ( // Learn from mistakes functions const startLearnFromMistakes = useCallback(async () => { - // First, ensure the entire game is analyzed at the required depth - if (!gameAnalysisProgress.isComplete) { - // Start analysis first + // Check if we have sufficient analysis for learn from mistakes + const mainLine = game.tree.getMainLine() + // For learn from mistakes, we need reasonable analysis (depth >= 12) rather than requiring exact depth 15 + // Most games are analyzed at depth 18 by default, so this should work + const minimumDepthForMistakeDetection = 12 + const hasEnoughAnalysis = mainLine.every( + (node) => { + // Skip root node (no move) + if (!node.move) return true + + // Skip terminal positions (checkmate/stalemate) - they don't need analysis + const chess = new Chess(node.fen) + if (chess.gameOver()) return true + + // Check if this position has sufficient analysis depth + return (node.analysis.stockfish?.depth ?? 0) >= minimumDepthForMistakeDetection + } + ) + + console.log('Learn from mistakes debug:', { + mainLineLength: mainLine.length, + hasEnoughAnalysis, + requiredDepth: minimumDepthForMistakeDetection, + allNodeDepths: mainLine.map((node, i) => { + const chess = new Chess(node.fen) + const isTerminal = chess.gameOver() + return { + index: i, + move: node.move || 'root', + depth: node.analysis.stockfish?.depth ?? 0, + hasStockfish: !!node.analysis.stockfish, + isTerminal, + meetsCriteria: !node.move || isTerminal || (node.analysis.stockfish?.depth ?? 0) >= minimumDepthForMistakeDetection + } + }), + terminalPositions: mainLine.filter(node => { + if (!node.move) return false + const chess = new Chess(node.fen) + return chess.gameOver() + }).length, + nodesMissingDepth: mainLine.filter(node => { + if (!node.move) return false + const chess = new Chess(node.fen) + if (chess.gameOver()) return false + return (node.analysis.stockfish?.depth ?? 0) < minimumDepthForMistakeDetection + }).length + }) + + if (hasEnoughAnalysis) { + // Game already has enough analysis, initialize immediately + console.log('Initializing learn from mistakes immediately') + initializeLearnFromMistakes() + return Promise.resolve() + } else { + // Need to analyze the game first + console.log('Starting game analysis first') await startGameAnalysis(LEARN_FROM_MISTAKES_DEPTH) // Wait for analysis to complete @@ -353,21 +406,22 @@ export const useAnalysisController = ( } checkComplete() }) - } else { - initializeLearnFromMistakes() } - }, [gameAnalysisProgress, startGameAnalysis]) + }, [gameAnalysisProgress, startGameAnalysis, game.tree]) const initializeLearnFromMistakes = useCallback(() => { + console.log('initializeLearnFromMistakes called') // Determine which player to analyze based on the current user // For now, we'll analyze the user playing as white by default // This could be enhanced to detect which player is the user const playerColor: 'white' | 'black' = 'white' // This could be made configurable const mistakes = extractPlayerMistakes(game.tree, playerColor) + console.log('Extracted mistakes:', mistakes.length, mistakes) if (mistakes.length === 0) { // No mistakes found - could show a message + console.log('No mistakes found for player:', playerColor) return } @@ -377,6 +431,12 @@ export const useAnalysisController = ( const originalPosition = mistakeNode && mistakeNode.parent ? mistakeNode.parent.fen : null + console.log('Setting up first mistake:', { + firstMistake, + mistakeNode: mistakeNode?.move, + originalPosition: originalPosition?.slice(0, 20) + '...' + }) + setLearnFromMistakesState({ isActive: true, currentMistakeIndex: 0, @@ -389,6 +449,7 @@ export const useAnalysisController = ( }) if (mistakeNode && mistakeNode.parent) { + console.log('Navigating to mistake parent position') controller.setCurrentNode(mistakeNode.parent) } }, [game.tree, controller]) @@ -429,7 +490,7 @@ export const useAnalysisController = ( chess.fen(), currentMistake.bestMove, currentMistake.bestMoveSan, - controller.currentMaiaModel, + currentMaiaModel, ) controller.goToNode(newVariation) } From 95b5d60707c189995cf757fd16525278e106885b Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Sat, 26 Jul 2025 23:05:49 -0400 Subject: [PATCH 5/5] feat: allow assigning colour to analyze mistakes of --- src/components/Analysis/AnalysisGameList.tsx | 12 +- .../Analysis/ConfigurableScreens.tsx | 12 +- src/components/Analysis/LearnFromMistakes.tsx | 75 +++++- .../Leaderboard/LeaderboardNavBadge.tsx | 4 +- .../useAnalysisController.ts | 216 +++++++++--------- src/pages/analysis/[...id].tsx | 10 + src/types/analysis/index.ts | 2 + 7 files changed, 207 insertions(+), 124 deletions(-) diff --git a/src/components/Analysis/AnalysisGameList.tsx b/src/components/Analysis/AnalysisGameList.tsx index 7fc6e984..5c9007ba 100644 --- a/src/components/Analysis/AnalysisGameList.tsx +++ b/src/components/Analysis/AnalysisGameList.tsx @@ -681,17 +681,11 @@ export const AnalysisGameList: React.FC = ({ getCurrentGames().length === 0 && !loading && (
    - - star - -

    +

    {selected === 'favorites' - ? 'Hit the star to favorite games...' - : 'Play more games...'} + ? ' ⭐ Hit the star to favourite games...' + : 'Play more games... ^. .^₎⟆'}

    -

    ₍^. .^₎⟆

    )}
    diff --git a/src/components/Analysis/ConfigurableScreens.tsx b/src/components/Analysis/ConfigurableScreens.tsx index a96ff783..1447afd6 100644 --- a/src/components/Analysis/ConfigurableScreens.tsx +++ b/src/components/Analysis/ConfigurableScreens.tsx @@ -37,6 +37,7 @@ interface Props { onShowSolution?: () => void onNextMistake?: () => void onStopLearnFromMistakes?: () => void + onSelectPlayer?: (color: 'white' | 'black') => void lastMoveResult?: 'correct' | 'incorrect' | 'not-learning' } @@ -58,6 +59,7 @@ export const ConfigurableScreens: React.FC = ({ onShowSolution, onNextMistake, onStopLearnFromMistakes, + onSelectPlayer, lastMoveResult, }) => { const screens = [ @@ -77,14 +79,14 @@ export const ConfigurableScreens: React.FC = ({ if ( isLearnFromMistakesActive && learnFromMistakesState && - learnFromMistakesCurrentInfo + (learnFromMistakesCurrentInfo || learnFromMistakesState.showPlayerSelection) ) { return (
    { @@ -103,6 +105,12 @@ export const ConfigurableScreens: React.FC = ({ /* no-op */ }) } + onSelectPlayer={ + onSelectPlayer || + (() => { + /* no-op */ + }) + } lastMoveResult={lastMoveResult || 'not-learning'} />
    diff --git a/src/components/Analysis/LearnFromMistakes.tsx b/src/components/Analysis/LearnFromMistakes.tsx index f8da65df..0c2cf64d 100644 --- a/src/components/Analysis/LearnFromMistakes.tsx +++ b/src/components/Analysis/LearnFromMistakes.tsx @@ -1,4 +1,5 @@ import React from 'react' +import Image from 'next/image' import { LearnFromMistakesState, MistakePosition } from 'src/types/analysis' interface Props { @@ -11,6 +12,7 @@ interface Props { onShowSolution: () => void onNext: () => void onStop: () => void + onSelectPlayer: (color: 'white' | 'black') => void lastMoveResult?: 'correct' | 'incorrect' | 'not-learning' } @@ -20,9 +22,80 @@ export const LearnFromMistakes: React.FC = ({ onShowSolution, onNext, onStop, + onSelectPlayer, lastMoveResult, }) => { - if (!state.isActive || !currentInfo) { + if (!state.isActive) { + return null + } + + // Show player selection dialog + if (state.showPlayerSelection) { + return ( +
    +
    +
    +
    +
    + + school + +

    + Learn from your mistakes +

    +
    + +
    + +
    +

    + Choose which player's mistakes you'd like to learn + from: +

    +
    + + +
    +
    +
    +
    +
    + ) + } + + if (!currentInfo) { return null } diff --git a/src/components/Leaderboard/LeaderboardNavBadge.tsx b/src/components/Leaderboard/LeaderboardNavBadge.tsx index b86e6431..d9fafd71 100644 --- a/src/components/Leaderboard/LeaderboardNavBadge.tsx +++ b/src/components/Leaderboard/LeaderboardNavBadge.tsx @@ -33,8 +33,8 @@ export const LeaderboardNavBadge: React.FC = ({ trophy - {status.totalLeaderboards > 1 && ( - + {status.totalLeaderboards > 0 && ( + {status.totalLeaderboards} )} diff --git a/src/hooks/useAnalysisController/useAnalysisController.ts b/src/hooks/useAnalysisController/useAnalysisController.ts index 9b326499..c6ebb00a 100644 --- a/src/hooks/useAnalysisController/useAnalysisController.ts +++ b/src/hooks/useAnalysisController/useAnalysisController.ts @@ -8,6 +8,7 @@ import { useCallback, useRef, } from 'react' +import toast from 'react-hot-toast' import { AnalyzedGame, GameNode } from 'src/types' import type { DrawShape } from 'chessground/draw' @@ -320,6 +321,8 @@ export const useAnalysisController = ( const [learnFromMistakesState, setLearnFromMistakesState] = useState({ isActive: false, + showPlayerSelection: false, + selectedPlayerColor: null, currentMistakeIndex: 0, mistakes: [], hasCompletedAnalysis: false, @@ -331,132 +334,124 @@ export const useAnalysisController = ( // Learn from mistakes functions const startLearnFromMistakes = useCallback(async () => { - // Check if we have sufficient analysis for learn from mistakes - const mainLine = game.tree.getMainLine() - // For learn from mistakes, we need reasonable analysis (depth >= 12) rather than requiring exact depth 15 - // Most games are analyzed at depth 18 by default, so this should work - const minimumDepthForMistakeDetection = 12 - const hasEnoughAnalysis = mainLine.every( - (node) => { + // Show player selection dialog first + setLearnFromMistakesState((prev) => ({ + ...prev, + showPlayerSelection: true, + isActive: true, + })) + }, []) + + const startLearnFromMistakesWithColor = useCallback( + async (playerColor: 'white' | 'black') => { + // Check if we have sufficient analysis for learn from mistakes + const mainLine = game.tree.getMainLine() + // For learn from mistakes, we need reasonable analysis (depth >= 12) rather than requiring exact depth 15 + // Most games are analyzed at depth 18 by default, so this should work + const minimumDepthForMistakeDetection = 12 + const hasEnoughAnalysis = mainLine.every((node) => { // Skip root node (no move) if (!node.move) return true - + // Skip terminal positions (checkmate/stalemate) - they don't need analysis const chess = new Chess(node.fen) if (chess.gameOver()) return true - - // Check if this position has sufficient analysis depth - return (node.analysis.stockfish?.depth ?? 0) >= minimumDepthForMistakeDetection - } - ) - console.log('Learn from mistakes debug:', { - mainLineLength: mainLine.length, - hasEnoughAnalysis, - requiredDepth: minimumDepthForMistakeDetection, - allNodeDepths: mainLine.map((node, i) => { - const chess = new Chess(node.fen) - const isTerminal = chess.gameOver() - return { - index: i, - move: node.move || 'root', - depth: node.analysis.stockfish?.depth ?? 0, - hasStockfish: !!node.analysis.stockfish, - isTerminal, - meetsCriteria: !node.move || isTerminal || (node.analysis.stockfish?.depth ?? 0) >= minimumDepthForMistakeDetection - } - }), - terminalPositions: mainLine.filter(node => { - if (!node.move) return false - const chess = new Chess(node.fen) - return chess.gameOver() - }).length, - nodesMissingDepth: mainLine.filter(node => { - if (!node.move) return false - const chess = new Chess(node.fen) - if (chess.gameOver()) return false - return (node.analysis.stockfish?.depth ?? 0) < minimumDepthForMistakeDetection - }).length - }) + // Check if this position has sufficient analysis depth + return ( + (node.analysis.stockfish?.depth ?? 0) >= + minimumDepthForMistakeDetection + ) + }) - if (hasEnoughAnalysis) { - // Game already has enough analysis, initialize immediately - console.log('Initializing learn from mistakes immediately') - initializeLearnFromMistakes() - return Promise.resolve() - } else { - // Need to analyze the game first - console.log('Starting game analysis first') - await startGameAnalysis(LEARN_FROM_MISTAKES_DEPTH) - - // Wait for analysis to complete - return new Promise((resolve) => { - const checkComplete = () => { - if ( - gameAnalysisProgress.isComplete || - gameAnalysisProgress.isCancelled - ) { - if (gameAnalysisProgress.isComplete) { - initializeLearnFromMistakes() + if (hasEnoughAnalysis) { + // Game already has enough analysis, initialize immediately + initializeLearnFromMistakesWithColor(playerColor) + return Promise.resolve() + } else { + // Need to analyze the game first + await startGameAnalysis(LEARN_FROM_MISTAKES_DEPTH) + + // Wait for analysis to complete + return new Promise((resolve) => { + const checkComplete = () => { + if ( + gameAnalysisProgress.isComplete || + gameAnalysisProgress.isCancelled + ) { + if (gameAnalysisProgress.isComplete) { + initializeLearnFromMistakesWithColor(playerColor) + } + resolve() + } else { + setTimeout(checkComplete, 500) } - resolve() - } else { - setTimeout(checkComplete, 500) } - } - checkComplete() - }) - } - }, [gameAnalysisProgress, startGameAnalysis, game.tree]) - - const initializeLearnFromMistakes = useCallback(() => { - console.log('initializeLearnFromMistakes called') - // Determine which player to analyze based on the current user - // For now, we'll analyze the user playing as white by default - // This could be enhanced to detect which player is the user - const playerColor: 'white' | 'black' = 'white' // This could be made configurable - - const mistakes = extractPlayerMistakes(game.tree, playerColor) - console.log('Extracted mistakes:', mistakes.length, mistakes) - - if (mistakes.length === 0) { - // No mistakes found - could show a message - console.log('No mistakes found for player:', playerColor) - return - } - - // Navigate to the first mistake position (the position where the player needs to move) - const firstMistake = mistakes[0] - const mistakeNode = game.tree.getMainLine()[firstMistake.moveIndex] - const originalPosition = - mistakeNode && mistakeNode.parent ? mistakeNode.parent.fen : null + checkComplete() + }) + } + }, + [gameAnalysisProgress, startGameAnalysis, game.tree], + ) - console.log('Setting up first mistake:', { - firstMistake, - mistakeNode: mistakeNode?.move, - originalPosition: originalPosition?.slice(0, 20) + '...' - }) + const initializeLearnFromMistakesWithColor = useCallback( + (playerColor: 'white' | 'black') => { + const mistakes = extractPlayerMistakes(game.tree, playerColor) + + if (mistakes.length === 0) { + // No mistakes found - show toast and reset state + const colorName = playerColor === 'white' ? 'White' : 'Black' + toast(`No clear mistakes made by ${colorName}`, { + icon: '👑', + duration: 3000, + }) + + setLearnFromMistakesState({ + isActive: false, + showPlayerSelection: false, + selectedPlayerColor: null, + currentMistakeIndex: 0, + mistakes: [], + hasCompletedAnalysis: false, + showSolution: false, + currentAttempt: 1, + maxAttempts: Infinity, + originalPosition: null, + }) + return + } - setLearnFromMistakesState({ - isActive: true, - currentMistakeIndex: 0, - mistakes, - hasCompletedAnalysis: true, - showSolution: false, - currentAttempt: 1, - maxAttempts: Infinity, - originalPosition, - }) + // Navigate to the first mistake position (the position where the player needs to move) + const firstMistake = mistakes[0] + const mistakeNode = game.tree.getMainLine()[firstMistake.moveIndex] + const originalPosition = + mistakeNode && mistakeNode.parent ? mistakeNode.parent.fen : null + + setLearnFromMistakesState({ + isActive: true, + showPlayerSelection: false, + selectedPlayerColor: playerColor, + currentMistakeIndex: 0, + mistakes, + hasCompletedAnalysis: true, + showSolution: false, + currentAttempt: 1, + maxAttempts: Infinity, + originalPosition, + }) - if (mistakeNode && mistakeNode.parent) { - console.log('Navigating to mistake parent position') - controller.setCurrentNode(mistakeNode.parent) - } - }, [game.tree, controller]) + if (mistakeNode && mistakeNode.parent) { + controller.setCurrentNode(mistakeNode.parent) + } + }, + [game.tree, controller], + ) const stopLearnFromMistakes = useCallback(() => { setLearnFromMistakesState({ isActive: false, + showPlayerSelection: false, + selectedPlayerColor: null, currentMistakeIndex: 0, mistakes: [], hasCompletedAnalysis: false, @@ -775,6 +770,7 @@ export const useAnalysisController = ( learnFromMistakes: { state: learnFromMistakesState, start: startLearnFromMistakes, + startWithColor: startLearnFromMistakesWithColor, stop: stopLearnFromMistakes, showSolution, goToNext: goToNextMistake, diff --git a/src/pages/analysis/[...id].tsx b/src/pages/analysis/[...id].tsx index e4c0462a..28757e06 100644 --- a/src/pages/analysis/[...id].tsx +++ b/src/pages/analysis/[...id].tsx @@ -474,6 +474,14 @@ const Analysis: React.FC = ({ setAnalysisEnabled(false) // Auto-disable analysis when going to next mistake }, [controller.learnFromMistakes]) + const handleSelectPlayer = useCallback( + (color: 'white' | 'black') => { + controller.learnFromMistakes.startWithColor(color) + setAnalysisEnabled(false) // Auto-disable analysis when starting learn mode + }, + [controller.learnFromMistakes], + ) + // Create empty data structures for when analysis is disabled const emptyBlunderMeterData = useMemo( () => ({ @@ -924,6 +932,7 @@ const Analysis: React.FC = ({ onShowSolution={handleShowSolution} onNextMistake={handleNextMistake} onStopLearnFromMistakes={handleStopLearnFromMistakes} + onSelectPlayer={handleSelectPlayer} lastMoveResult={lastMoveResult} /> @@ -1363,6 +1372,7 @@ const Analysis: React.FC = ({ onShowSolution={handleShowSolution} onNextMistake={handleNextMistake} onStopLearnFromMistakes={handleStopLearnFromMistakes} + onSelectPlayer={handleSelectPlayer} lastMoveResult={lastMoveResult} /> diff --git a/src/types/analysis/index.ts b/src/types/analysis/index.ts index 87dba987..6143e3ab 100644 --- a/src/types/analysis/index.ts +++ b/src/types/analysis/index.ts @@ -156,6 +156,8 @@ export interface MistakePosition { export interface LearnFromMistakesState { isActive: boolean + showPlayerSelection: boolean + selectedPlayerColor: 'white' | 'black' | null currentMistakeIndex: number mistakes: MistakePosition[] hasCompletedAnalysis: boolean