Skip to content

Commit 749bd7c

Browse files
Implement client-side analysis caching with auto-save and load functionality
Co-authored-by: kevinjosethomas <46242684+kevinjosethomas@users.noreply.github.com>
1 parent c215dc7 commit 749bd7c

File tree

4 files changed

+281
-3
lines changed

4 files changed

+281
-3
lines changed

src/api/analysis/analysis.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,3 +662,62 @@ export const getAnalyzedUserGame = async (
662662
type: 'brain',
663663
} as AnalyzedGame
664664
}
665+
666+
// Types for engine analysis storage
667+
export interface EngineAnalysisPosition {
668+
ply: number
669+
fen: string
670+
maia?: { [rating: string]: MaiaEvaluation }
671+
stockfish?: {
672+
depth: number
673+
cp_vec: { [move: string]: number }
674+
}
675+
}
676+
677+
export interface EngineAnalysisData {
678+
positions: EngineAnalysisPosition[]
679+
}
680+
681+
// Store client-side engine analysis to backend
682+
export const storeEngineAnalysis = async (
683+
gameId: string,
684+
analysisData: EngineAnalysisData,
685+
): Promise<void> => {
686+
const res = await fetch(buildUrl(`analysis/store_engine_analysis/${gameId}`), {
687+
method: 'POST',
688+
headers: {
689+
'Content-Type': 'application/json',
690+
},
691+
body: JSON.stringify(analysisData),
692+
})
693+
694+
if (res.status === 401) {
695+
throw new Error('Unauthorized')
696+
}
697+
698+
if (!res.ok) {
699+
throw new Error('Failed to store engine analysis')
700+
}
701+
}
702+
703+
// Retrieve stored engine analysis from backend
704+
export const getEngineAnalysis = async (
705+
gameId: string,
706+
): Promise<EngineAnalysisData | null> => {
707+
const res = await fetch(buildUrl(`analysis/get_engine_analysis/${gameId}`))
708+
709+
if (res.status === 401) {
710+
throw new Error('Unauthorized')
711+
}
712+
713+
if (res.status === 404) {
714+
// No stored analysis found
715+
return null
716+
}
717+
718+
if (!res.ok) {
719+
throw new Error('Failed to retrieve engine analysis')
720+
}
721+
722+
return res.json()
723+
}

src/hooks/useAnalysisController/useAnalysisController.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ import { useMoveRecommendations } from './useMoveRecommendations'
1919
import { MaiaEngineContext } from 'src/contexts/MaiaEngineContext'
2020
import { generateColorSanMapping, calculateBlunderMeter } from './utils'
2121
import { StockfishEngineContext } from 'src/contexts/StockfishEngineContext'
22+
import {
23+
collectEngineAnalysisData,
24+
generateAnalysisCacheKey
25+
} from 'src/lib/analysisStorage'
26+
import { storeEngineAnalysis } from 'src/api/analysis/analysis'
2227

2328
export interface GameAnalysisProgress {
2429
currentMoveIndex: number
@@ -74,6 +79,65 @@ export const useAnalysisController = (
7479
currentNode: null,
7580
})
7681

82+
// Auto-save analysis state
83+
const [lastSavedCacheKey, setLastSavedCacheKey] = useState<string>('')
84+
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null)
85+
86+
// Auto-save analysis data every 10 seconds
87+
const saveAnalysisToBackend = useCallback(async () => {
88+
// Only save for games that have a proper ID (not custom local games)
89+
if (!game.id || game.type === 'custom-pgn' || game.type === 'custom-fen') {
90+
return
91+
}
92+
93+
try {
94+
const analysisData = collectEngineAnalysisData(game.tree)
95+
96+
// Only save if there's actually some analysis to save
97+
if (analysisData.positions.length === 0) {
98+
return
99+
}
100+
101+
// Generate a cache key to avoid unnecessary saves
102+
const cacheKey = generateAnalysisCacheKey(analysisData)
103+
if (cacheKey === lastSavedCacheKey) {
104+
return // No new analysis since last save
105+
}
106+
107+
await storeEngineAnalysis(game.id, analysisData)
108+
setLastSavedCacheKey(cacheKey)
109+
console.log('Analysis saved to backend:', analysisData.positions.length, 'positions')
110+
} catch (error) {
111+
console.warn('Failed to save analysis to backend:', error)
112+
// Don't show error to user as this is background functionality
113+
}
114+
}, [game.id, game.type, game.tree, lastSavedCacheKey])
115+
116+
// Setup auto-save timer
117+
useEffect(() => {
118+
// Clear existing timer
119+
if (autoSaveTimerRef.current) {
120+
clearInterval(autoSaveTimerRef.current)
121+
}
122+
123+
// Set up new timer to save every 10 seconds
124+
autoSaveTimerRef.current = setInterval(saveAnalysisToBackend, 10000)
125+
126+
// Cleanup on unmount
127+
return () => {
128+
if (autoSaveTimerRef.current) {
129+
clearInterval(autoSaveTimerRef.current)
130+
}
131+
}
132+
}, [saveAnalysisToBackend])
133+
134+
// Save immediately when analysis state changes (new analysis available)
135+
useEffect(() => {
136+
// Debounce the save to avoid too frequent calls
137+
const timeoutId = setTimeout(saveAnalysisToBackend, 1000)
138+
return () => clearTimeout(timeoutId)
139+
}, [analysisState, saveAnalysisToBackend])
140+
77141
// Simple batch analysis functions that reuse existing analysis infrastructure
78142
const startGameAnalysis = useCallback(
79143
async (targetDepth: number) => {

src/lib/analysisStorage.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { GameTree, GameNode } from 'src/types'
2+
import {
3+
EngineAnalysisData,
4+
EngineAnalysisPosition,
5+
} from 'src/api/analysis/analysis'
6+
7+
/**
8+
* Collects analysis data from a game tree to send to the backend
9+
*/
10+
export const collectEngineAnalysisData = (
11+
gameTree: GameTree,
12+
): EngineAnalysisData => {
13+
const positions: EngineAnalysisPosition[] = []
14+
const mainLine = gameTree.getMainLine()
15+
16+
mainLine.forEach((node, index) => {
17+
// Only include positions that have some analysis
18+
if (!node.analysis.maia && !node.analysis.stockfish) {
19+
return
20+
}
21+
22+
const position: EngineAnalysisPosition = {
23+
ply: index,
24+
fen: node.fen,
25+
}
26+
27+
// Add Maia analysis if available
28+
if (node.analysis.maia) {
29+
position.maia = node.analysis.maia
30+
}
31+
32+
// Add Stockfish analysis if available
33+
if (node.analysis.stockfish) {
34+
position.stockfish = {
35+
depth: node.analysis.stockfish.depth,
36+
cp_vec: node.analysis.stockfish.cp_vec,
37+
}
38+
}
39+
40+
positions.push(position)
41+
})
42+
43+
return { positions }
44+
}
45+
46+
/**
47+
* Applies stored analysis data back to a game tree
48+
*/
49+
export const applyEngineAnalysisData = (
50+
gameTree: GameTree,
51+
analysisData: EngineAnalysisData,
52+
): void => {
53+
const mainLine = gameTree.getMainLine()
54+
55+
analysisData.positions.forEach((positionData) => {
56+
const { ply, maia, stockfish } = positionData
57+
58+
// Find the corresponding node (ply is the index in the main line)
59+
if (ply >= 0 && ply < mainLine.length) {
60+
const node = mainLine[ply]
61+
62+
// Verify FEN matches to ensure we're applying to the correct position
63+
if (node.fen === positionData.fen) {
64+
// Apply Maia analysis
65+
if (maia) {
66+
node.addMaiaAnalysis(maia)
67+
}
68+
69+
// Apply Stockfish analysis
70+
if (stockfish) {
71+
// Create a StockfishEvaluation object with minimal required fields
72+
const stockfishEval = {
73+
sent: true,
74+
depth: stockfish.depth,
75+
model_move: Object.keys(stockfish.cp_vec)[0] || '',
76+
model_optimal_cp: Math.max(...Object.values(stockfish.cp_vec).map(Number), 0),
77+
cp_vec: stockfish.cp_vec,
78+
cp_relative_vec: calculateRelativeCp(stockfish.cp_vec),
79+
}
80+
81+
// Only apply if we don't have deeper analysis already
82+
if (
83+
!node.analysis.stockfish ||
84+
node.analysis.stockfish.depth < stockfish.depth
85+
) {
86+
node.addStockfishAnalysis(stockfishEval)
87+
}
88+
}
89+
}
90+
}
91+
})
92+
}
93+
94+
/**
95+
* Helper function to calculate relative centipawn values
96+
*/
97+
const calculateRelativeCp = (cpVec: {
98+
[move: string]: number
99+
}): { [move: string]: number } => {
100+
const maxCp = Math.max(...Object.values(cpVec))
101+
const relativeCp: { [move: string]: number } = {}
102+
103+
Object.entries(cpVec).forEach(([move, cp]) => {
104+
relativeCp[move] = cp - maxCp
105+
})
106+
107+
return relativeCp
108+
}
109+
110+
/**
111+
* Generate a unique cache key for analysis data
112+
*/
113+
export const generateAnalysisCacheKey = (analysisData: EngineAnalysisData): string => {
114+
// Create a hash-like key based on positions and their analysis
115+
const keyData = analysisData.positions.map(pos => ({
116+
ply: pos.ply,
117+
fen: pos.fen,
118+
hasStockfish: !!pos.stockfish,
119+
stockfishDepth: pos.stockfish?.depth || 0,
120+
hasMaia: !!pos.maia,
121+
maiaModels: pos.maia ? Object.keys(pos.maia).sort() : []
122+
}))
123+
124+
return JSON.stringify(keyData)
125+
}

src/pages/analysis/[...id].tsx

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getAnalyzedCustomPGN,
1616
getAnalyzedCustomFEN,
1717
getAnalyzedCustomGame,
18+
getEngineAnalysis,
1819
} from 'src/api'
1920
import {
2021
AnalyzedGame,
@@ -53,6 +54,7 @@ import { useAnalysisController } from 'src/hooks'
5354
import { tourConfigs } from 'src/constants/tours'
5455
import type { DrawShape } from 'chessground/draw'
5556
import { MAIA_MODELS } from 'src/constants/common'
57+
import { applyEngineAnalysisData } from 'src/lib/analysisStorage'
5658

5759
const AnalysisPage: NextPage = () => {
5860
const { startTour, tourState } = useTour()
@@ -75,6 +77,25 @@ const AnalysisPage: NextPage = () => {
7577
}, [initialTourCheck, startTour, tourState.ready])
7678
const [currentId, setCurrentId] = useState<string[]>(id as string[])
7779

80+
// Helper function to load and apply stored analysis
81+
const loadStoredAnalysis = useCallback(async (game: AnalyzedGame) => {
82+
// Only load for games that have a proper ID (not custom local games)
83+
if (!game.id || game.type === 'custom-pgn' || game.type === 'custom-fen') {
84+
return
85+
}
86+
87+
try {
88+
const storedAnalysis = await getEngineAnalysis(game.id)
89+
if (storedAnalysis && storedAnalysis.positions.length > 0) {
90+
applyEngineAnalysisData(game.tree, storedAnalysis)
91+
console.log('Loaded stored analysis:', storedAnalysis.positions.length, 'positions')
92+
}
93+
} catch (error) {
94+
console.warn('Failed to load stored analysis:', error)
95+
// Don't show error to user as this is background functionality
96+
}
97+
}, [])
98+
7899
const getAndSetTournamentGame = useCallback(
79100
async (
80101
newId: string[],
@@ -101,13 +122,16 @@ const AnalysisPage: NextPage = () => {
101122
setAnalyzedGame({ ...game, type: 'tournament' })
102123
setCurrentId(newId)
103124

125+
// Load stored analysis
126+
await loadStoredAnalysis({ ...game, type: 'tournament' })
127+
104128
if (updateUrl) {
105129
router.push(`/analysis/${newId.join('/')}`, undefined, {
106130
shallow: true,
107131
})
108132
}
109133
},
110-
[router],
134+
[router, loadStoredAnalysis],
111135
)
112136

113137
const getAndSetLichessGame = useCallback(
@@ -132,11 +156,14 @@ const AnalysisPage: NextPage = () => {
132156
})
133157
setCurrentId([id, 'pgn'])
134158

159+
// Load stored analysis
160+
await loadStoredAnalysis({ ...game, type: 'pgn' })
161+
135162
if (updateUrl) {
136163
router.push(`/analysis/${id}/pgn`, undefined, { shallow: true })
137164
}
138165
},
139-
[router],
166+
[router, loadStoredAnalysis],
140167
)
141168

142169
const getAndSetUserGame = useCallback(
@@ -158,13 +185,16 @@ const AnalysisPage: NextPage = () => {
158185
setAnalyzedGame({ ...game, type })
159186
setCurrentId([id, type])
160187

188+
// Load stored analysis
189+
await loadStoredAnalysis({ ...game, type })
190+
161191
if (updateUrl) {
162192
router.push(`/analysis/${id}/${type}`, undefined, {
163193
shallow: true,
164194
})
165195
}
166196
},
167-
[router],
197+
[router, loadStoredAnalysis],
168198
)
169199

170200
const getAndSetCustomGame = useCallback(

0 commit comments

Comments
 (0)