Skip to content

Commit 167c1cb

Browse files
Merge pull request #158 from CSSLab/copilot/fix-154
Implement client-side Stockfish/Maia analysis caching with auto-save and load functionality
2 parents 7956690 + ff41c20 commit 167c1cb

File tree

8 files changed

+456
-5
lines changed

8 files changed

+456
-5
lines changed

src/api/analysis/analysis.ts

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

src/components/Analysis/ConfigurableScreens.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ interface Props {
1414
onDeleteCustomGame?: () => void
1515
onAnalyzeEntireGame?: () => void
1616
isAnalysisInProgress?: boolean
17+
autoSave?: {
18+
hasUnsavedChanges: boolean
19+
isSaving: boolean
20+
status: 'saving' | 'unsaved' | 'saved'
21+
}
1722
}
1823

1924
export const ConfigurableScreens: React.FC<Props> = ({
@@ -26,6 +31,7 @@ export const ConfigurableScreens: React.FC<Props> = ({
2631
onDeleteCustomGame,
2732
onAnalyzeEntireGame,
2833
isAnalysisInProgress,
34+
autoSave,
2935
}) => {
3036
const screens = [
3137
{
@@ -82,6 +88,7 @@ export const ConfigurableScreens: React.FC<Props> = ({
8288
onDeleteCustomGame={onDeleteCustomGame}
8389
onAnalyzeEntireGame={onAnalyzeEntireGame}
8490
isAnalysisInProgress={isAnalysisInProgress}
91+
autoSave={autoSave}
8592
/>
8693
) : screen.id === 'export' ? (
8794
<div className="flex w-full flex-col p-3">

src/components/Analysis/ConfigureAnalysis.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import Link from 'next/link'
21
import React from 'react'
3-
import { useLocalStorage } from 'src/hooks'
42
import { AnalyzedGame } from 'src/types'
53

64
import { ContinueAgainstMaia } from 'src/components'
@@ -14,6 +12,11 @@ interface Props {
1412
onDeleteCustomGame?: () => void
1513
onAnalyzeEntireGame?: () => void
1614
isAnalysisInProgress?: boolean
15+
autoSave?: {
16+
hasUnsavedChanges: boolean
17+
isSaving: boolean
18+
status: 'saving' | 'unsaved' | 'saved'
19+
}
1720
}
1821

1922
export const ConfigureAnalysis: React.FC<Props> = ({
@@ -25,6 +28,7 @@ export const ConfigureAnalysis: React.FC<Props> = ({
2528
onDeleteCustomGame,
2629
onAnalyzeEntireGame,
2730
isAnalysisInProgress = false,
31+
autoSave,
2832
}: Props) => {
2933
const isCustomGame = game.type === 'custom-pgn' || game.type === 'custom-fen'
3034

@@ -66,6 +70,43 @@ export const ConfigureAnalysis: React.FC<Props> = ({
6670
</div>
6771
</button>
6872
)}
73+
{autoSave &&
74+
game.type !== 'custom-pgn' &&
75+
game.type !== 'custom-fen' &&
76+
game.type !== 'tournament' && (
77+
<div className="mt-2 w-full">
78+
<div className="flex items-center gap-1.5">
79+
{autoSave.status === 'saving' && (
80+
<>
81+
<div className="h-2 w-2 animate-spin rounded-full border border-secondary border-t-primary"></div>
82+
<span className="text-xs text-secondary">
83+
Saving analysis...
84+
</span>
85+
</>
86+
)}
87+
{autoSave.status === 'unsaved' && (
88+
<>
89+
<span className="material-symbols-outlined !text-sm text-orange-400">
90+
sync_problem
91+
</span>
92+
<span className="text-xs text-orange-400">
93+
Unsaved analysis. Will auto-save...
94+
</span>
95+
</>
96+
)}
97+
{autoSave.status === 'saved' && (
98+
<>
99+
<span className="material-symbols-outlined !text-sm text-green-400">
100+
cloud_done
101+
</span>
102+
<span className="text-xs text-green-400">
103+
Analysis auto-saved
104+
</span>
105+
</>
106+
)}
107+
</div>
108+
</div>
109+
)}
69110
{isCustomGame && onDeleteCustomGame && (
70111
<div className="mt-2 w-full">
71112
<button

src/hooks/useAnalysisController/useAnalysisController.ts

Lines changed: 120 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
@@ -36,6 +41,7 @@ export interface GameAnalysisConfig {
3641
export const useAnalysisController = (
3742
game: AnalyzedGame,
3843
initialOrientation?: 'white' | 'black',
44+
enableAutoSave = true,
3945
) => {
4046
const defaultOrientation = initialOrientation
4147
? initialOrientation
@@ -74,6 +80,110 @@ export const useAnalysisController = (
7480
currentNode: null,
7581
})
7682

83+
const [lastSavedCacheKey, setLastSavedCacheKey] = useState<string>('')
84+
const [hasUnsavedAnalysis, setHasUnsavedAnalysis] = useState(false)
85+
const [isAutoSaving, setIsAutoSaving] = useState(false)
86+
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null)
87+
88+
const saveAnalysisToBackend = useCallback(async () => {
89+
if (
90+
!enableAutoSave ||
91+
!game.id ||
92+
game.type === 'custom-pgn' ||
93+
game.type === 'custom-fen' ||
94+
game.type === 'tournament'
95+
) {
96+
return
97+
}
98+
99+
// Don't save if there are no unsaved changes
100+
if (!hasUnsavedAnalysis) {
101+
return
102+
}
103+
104+
try {
105+
setIsAutoSaving(true)
106+
const analysisData = collectEngineAnalysisData(game.tree)
107+
108+
if (analysisData.length === 0) {
109+
setIsAutoSaving(false)
110+
return
111+
}
112+
113+
const hasMeaningfulAnalysis = analysisData.some(
114+
(pos) => (pos.stockfish && pos.stockfish.depth >= 12) || pos.maia,
115+
)
116+
117+
if (!hasMeaningfulAnalysis) {
118+
setIsAutoSaving(false)
119+
return
120+
}
121+
122+
const cacheKey = generateAnalysisCacheKey(analysisData)
123+
if (cacheKey === lastSavedCacheKey) {
124+
setIsAutoSaving(false)
125+
return
126+
}
127+
128+
await storeEngineAnalysis(game.id, analysisData)
129+
setLastSavedCacheKey(cacheKey)
130+
setHasUnsavedAnalysis(false) // Mark as saved
131+
console.log(
132+
'Analysis saved to backend:',
133+
analysisData.length,
134+
'positions',
135+
)
136+
} catch (error) {
137+
console.warn('Failed to save analysis to backend:', error)
138+
// Don't show error to user as this is background functionality
139+
} finally {
140+
setIsAutoSaving(false)
141+
}
142+
}, [
143+
enableAutoSave,
144+
game.id,
145+
game.type,
146+
game.tree,
147+
lastSavedCacheKey,
148+
hasUnsavedAnalysis,
149+
])
150+
151+
const saveAnalysisToBackendRef = useRef(saveAnalysisToBackend)
152+
saveAnalysisToBackendRef.current = saveAnalysisToBackend
153+
154+
useEffect(() => {
155+
setHasUnsavedAnalysis(false)
156+
setIsAutoSaving(false)
157+
setLastSavedCacheKey('')
158+
}, [game.id, game.type])
159+
160+
useEffect(() => {
161+
if (analysisState > 0) {
162+
setHasUnsavedAnalysis(true)
163+
}
164+
}, [analysisState])
165+
166+
useEffect(() => {
167+
if (!enableAutoSave) {
168+
return
169+
}
170+
171+
if (autoSaveTimerRef.current) {
172+
clearInterval(autoSaveTimerRef.current)
173+
}
174+
175+
autoSaveTimerRef.current = setInterval(() => {
176+
saveAnalysisToBackendRef.current()
177+
}, 10000)
178+
179+
return () => {
180+
if (autoSaveTimerRef.current) {
181+
clearInterval(autoSaveTimerRef.current)
182+
}
183+
saveAnalysisToBackendRef.current()
184+
}
185+
}, [game.id, enableAutoSave])
186+
77187
// Simple batch analysis functions that reuse existing analysis infrastructure
78188
const startGameAnalysis = useCallback(
79189
async (targetDepth: number) => {
@@ -362,6 +472,16 @@ export const useAnalysisController = (
362472
cancelAnalysis: cancelGameAnalysis,
363473
resetProgress: resetGameAnalysisProgress,
364474
isEnginesReady: stockfish.isReady() && maia.status === 'ready',
475+
saveAnalysis: saveAnalysisToBackend,
476+
autoSave: {
477+
hasUnsavedChanges: hasUnsavedAnalysis,
478+
isSaving: isAutoSaving,
479+
status: isAutoSaving
480+
? ('saving' as const)
481+
: hasUnsavedAnalysis
482+
? ('unsaved' as const)
483+
: ('saved' as const),
484+
},
365485
},
366486
}
367487
}

0 commit comments

Comments
 (0)