diff --git a/.eslintrc b/.eslintrc
index 7ff09b78..8f3cfac6 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -1,4 +1,5 @@
{
+ "root": true,
"plugins": ["@typescript-eslint", "prettier", "react", "react-hooks"],
"extends": [
"next/core-web-vitals",
@@ -18,6 +19,8 @@
"prettier/prettier": "error",
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "off",
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-unused-vars": "off",
"@next/next/no-html-link-for-pages": "off",
"import/no-named-as-default": "off",
diff --git a/.vscode/settings.json b/.vscode/settings.json
index a62c7237..1f0128a0 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -6,5 +6,7 @@
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[dockerfile]": {
"editor.defaultFormatter": "ms-azuretools.vscode-docker"
- }
+ },
+ "editor.tabCompletion": "on",
+ "github.copilot.nextEditSuggestions.enabled": true
}
diff --git a/__tests__/analysis/makeMove-fen.test.ts b/__tests__/analysis/makeMove-fen.test.ts
deleted file mode 100644
index 5643bb7a..00000000
--- a/__tests__/analysis/makeMove-fen.test.ts
+++ /dev/null
@@ -1,202 +0,0 @@
-import { GameTree, GameNode } from 'src/types/base/tree'
-import { Chess, PieceSymbol } from 'chess.ts'
-
-describe('Analysis Page makeMove Logic for FEN Positions', () => {
- // Simulate the makeMove function logic from the analysis page
- const simulateMakeMove = (
- gameTree: GameTree,
- currentNode: GameNode,
- move: string,
- currentMaiaModel?: string,
- ) => {
- const chess = new Chess(currentNode.fen)
- const moveAttempt = chess.move({
- from: move.slice(0, 2),
- to: move.slice(2, 4),
- promotion: move[4] ? (move[4] as PieceSymbol) : undefined,
- })
-
- if (moveAttempt) {
- const newFen = chess.fen()
- const moveString =
- moveAttempt.from +
- moveAttempt.to +
- (moveAttempt.promotion ? moveAttempt.promotion : '')
- const san = moveAttempt.san
-
- // This is the current logic from the analysis page that we need to fix
- if (currentNode.mainChild?.move === moveString) {
- return { type: 'navigate', node: currentNode.mainChild }
- } else {
- // ISSUE: Always creates variation, never main line for first move
- const newVariation = gameTree.addVariation(
- currentNode,
- newFen,
- moveString,
- san,
- currentMaiaModel,
- )
- return { type: 'variation', node: newVariation }
- }
- }
- return null
- }
-
- // Fixed version of makeMove logic
- const simulateFixedMakeMove = (
- gameTree: GameTree,
- currentNode: GameNode,
- move: string,
- currentMaiaModel?: string,
- ) => {
- const chess = new Chess(currentNode.fen)
- const moveAttempt = chess.move({
- from: move.slice(0, 2),
- to: move.slice(2, 4),
- promotion: move[4] ? (move[4] as PieceSymbol) : undefined,
- })
-
- if (moveAttempt) {
- const newFen = chess.fen()
- const moveString =
- moveAttempt.from +
- moveAttempt.to +
- (moveAttempt.promotion ? moveAttempt.promotion : '')
- const san = moveAttempt.san
-
- if (currentNode.mainChild?.move === moveString) {
- // Existing main line move - navigate to it
- return { type: 'navigate', node: currentNode.mainChild }
- } else if (!currentNode.mainChild) {
- // No main child exists - create main line move (FIX)
- const newMainMove = gameTree.addMainMove(
- currentNode,
- newFen,
- moveString,
- san,
- currentMaiaModel,
- )
- return { type: 'main', node: newMainMove }
- } else {
- // Main child exists but different move - create variation
- const newVariation = gameTree.addVariation(
- currentNode,
- newFen,
- moveString,
- san,
- currentMaiaModel,
- )
- return { type: 'variation', node: newVariation }
- }
- }
- return null
- }
-
- describe('Current behavior (broken)', () => {
- it('incorrectly creates variations for first move from FEN position', () => {
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // Simulate making the first move from FEN position
- const result = simulateMakeMove(tree, rootNode, 'f3g5')
-
- // ISSUE: First move incorrectly creates a variation instead of main line
- expect(result?.type).toBe('variation')
- expect(rootNode.mainChild).toBeNull() // No main line created
- expect(rootNode.children.length).toBe(1)
- expect(rootNode.getVariations().length).toBe(1) // Created as variation
-
- // The main line should only contain the root
- const mainLine = tree.getMainLine()
- expect(mainLine.length).toBe(1) // Only root, no main line progression
- })
-
- it('shows the problem when making multiple moves from FEN', () => {
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // Make first move
- const result1 = simulateMakeMove(tree, rootNode, 'f3g5')
- expect(result1?.type).toBe('variation')
-
- // Make second move from same position
- const result2 = simulateMakeMove(tree, rootNode, 'f3e5')
- expect(result2?.type).toBe('variation')
-
- // Both moves are variations, no main line established
- expect(rootNode.mainChild).toBeNull()
- expect(rootNode.getVariations().length).toBe(2)
- expect(tree.getMainLine().length).toBe(1) // Still just root
- })
- })
-
- describe('Fixed behavior', () => {
- it('correctly creates main line for first move from FEN position', () => {
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // Simulate making the first move from FEN position with fix
- const result = simulateFixedMakeMove(tree, rootNode, 'f3g5')
-
- // FIXED: First move creates main line
- expect(result?.type).toBe('main')
- expect(rootNode.mainChild).toBeTruthy() // Main line created
- expect(rootNode.mainChild?.isMainline).toBe(true)
- expect(rootNode.getVariations().length).toBe(0) // No variations yet
-
- // The main line should now contain root + first move
- const mainLine = tree.getMainLine()
- expect(mainLine.length).toBe(2) // Root + one move
- })
-
- it('correctly handles subsequent moves: main line extension and variations', () => {
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // First move - should create main line
- const result1 = simulateFixedMakeMove(tree, rootNode, 'f3g5')
- expect(result1?.type).toBe('main')
- const firstMove = result1?.node as GameNode
-
- // Second move from same position - should create variation
- const result2 = simulateFixedMakeMove(tree, rootNode, 'f3e5')
- expect(result2?.type).toBe('variation')
-
- // Third move extending main line - should be main line
- const result3 = simulateFixedMakeMove(tree, firstMove, 'd7d6')
- expect(result3?.type).toBe('main')
-
- // Verify final structure
- expect(rootNode.mainChild).toBeTruthy()
- expect(rootNode.getVariations().length).toBe(1) // One variation
- expect(tree.getMainLine().length).toBe(3) // Root + two main moves
- })
-
- it('correctly navigates to existing moves', () => {
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // Create a move first
- const result1 = simulateFixedMakeMove(tree, rootNode, 'f3g5')
- const existingNode = result1?.node
-
- // Try the same move again - should navigate to existing node
- const result2 = simulateFixedMakeMove(tree, rootNode, 'f3g5')
- expect(result2?.type).toBe('navigate')
- expect(result2?.node).toBe(existingNode)
-
- // Structure should remain unchanged
- expect(rootNode.children.length).toBe(1)
- })
- })
-})
diff --git a/__tests__/analysis/makeMove-variation-fix.test.ts b/__tests__/analysis/makeMove-variation-fix.test.ts
deleted file mode 100644
index dc8cca63..00000000
--- a/__tests__/analysis/makeMove-variation-fix.test.ts
+++ /dev/null
@@ -1,251 +0,0 @@
-import { GameTree, GameNode } from 'src/types/base/tree'
-import { Chess, PieceSymbol } from 'chess.ts'
-
-describe('makeMove Logic - Variation Continuation Test', () => {
- // Test specifically for Kevin's feedback: when we're in a variation and make a move,
- // it should continue the variation, not create a new main line
-
- it('should continue variation when making moves from variation nodes', () => {
- const initialFen =
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
- const gameTree = new GameTree(initialFen)
- const root = gameTree.getRoot()
-
- // Step 1: Create main line move (e2e4)
- const mainMove = gameTree.addMainMove(
- root,
- 'rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2',
- 'e2e4',
- 'e4',
- )
-
- // Step 2: Create a variation from root (d2d4)
- const variation = gameTree.addVariation(
- root,
- 'rnbqkbnr/ppp1pppp/8/3p4/3P4/8/PPP1PPPP/RNBQKBNR w KQkq d6 0 2',
- 'd2d4',
- 'd4',
- )
-
- // Verify setup
- expect(root.mainChild).toBe(mainMove)
- expect(root.mainChild?.isMainline).toBe(true)
- expect(variation.isMainline).toBe(false)
- expect(root.children).toHaveLength(2)
-
- // Step 3: Simulate makeMove logic when currentNode is the variation
- const simulateMakeMove = (currentNode: GameNode, move: string) => {
- const chess = new Chess(currentNode.fen)
- const moveAttempt = chess.move({
- from: move.slice(0, 2),
- to: move.slice(2, 4),
- promotion: move[4] ? (move[4] as PieceSymbol) : undefined,
- })
-
- if (moveAttempt) {
- const newFen = chess.fen()
- const moveString =
- moveAttempt.from + moveAttempt.to + (moveAttempt.promotion || '')
- const san = moveAttempt.san
-
- // This is the FIXED logic from the analysis page
- if (currentNode.mainChild?.move === moveString) {
- return { type: 'navigate', node: currentNode.mainChild }
- } else if (!currentNode.mainChild && currentNode.isMainline) {
- // Only create main line if no main child AND we're on main line
- const newMainMove = gameTree.addMainMove(
- currentNode,
- newFen,
- moveString,
- san,
- )
- return { type: 'main_line', node: newMainMove }
- } else {
- // Either main child exists but different move, OR we're in variation - create variation
- const newVariation = gameTree.addVariation(
- currentNode,
- newFen,
- moveString,
- san,
- )
- return { type: 'variation', node: newVariation }
- }
- }
- return null
- }
-
- // Step 4: Make move from the variation node (should create another variation, not main line)
- const result = simulateMakeMove(variation, 'g1f3') as {
- type: 'variation'
- node: GameNode
- }
-
- // Assertions
- expect(result).not.toBeNull()
- expect(result.type).toBe('variation')
- expect(result.node.isMainline).toBe(false)
- expect(variation.mainChild).toBeNull() // variation should not have gained a main child
- expect(variation.children).toHaveLength(1) // should have one child (the move we just made)
- expect(variation.children[0].isMainline).toBe(false) // that child should be a variation
- expect(variation.children[0].move).toBe('g1f3')
- expect(variation.children[0].san).toBe('Nf3')
- })
-
- it('should create main line when making first move from FEN on main line', () => {
- const customFen =
- 'r1bqkbnr/pppp1ppp/2n5/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 2 3'
- const gameTree = new GameTree(customFen)
- const root = gameTree.getRoot()
-
- const simulateMakeMove = (currentNode: GameNode, move: string) => {
- const chess = new Chess(currentNode.fen)
- const moveAttempt = chess.move({
- from: move.slice(0, 2),
- to: move.slice(2, 4),
- promotion: move[4] ? (move[4] as PieceSymbol) : undefined,
- })
-
- if (moveAttempt) {
- const newFen = chess.fen()
- const moveString =
- moveAttempt.from + moveAttempt.to + (moveAttempt.promotion || '')
- const san = moveAttempt.san
-
- if (currentNode.mainChild?.move === moveString) {
- return { type: 'navigate', node: currentNode.mainChild }
- } else if (!currentNode.mainChild && currentNode.isMainline) {
- const newMainMove = gameTree.addMainMove(
- currentNode,
- newFen,
- moveString,
- san,
- )
- return { type: 'main_line', node: newMainMove }
- } else {
- const newVariation = gameTree.addVariation(
- currentNode,
- newFen,
- moveString,
- san,
- )
- return { type: 'variation', node: newVariation }
- }
- }
- return null
- }
-
- // Make first move from FEN position (should be main line since root is on main line)
- const result = simulateMakeMove(root, 'g1f3') as {
- type: 'main_line'
- node: GameNode
- }
-
- expect(result).not.toBeNull()
- expect(result.type).toBe('main_line')
- expect(result.node.isMainline).toBe(true)
- expect(root.mainChild).toBe(result.node)
- })
-
- it('should handle complex variation tree correctly', () => {
- const initialFen =
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
- const gameTree = new GameTree(initialFen)
- const root = gameTree.getRoot()
-
- // Create main line: e4
- const e4 = gameTree.addMainMove(
- root,
- 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
- 'e2e4',
- 'e4',
- )
-
- // Create variation from root: d4
- const d4 = gameTree.addVariation(
- root,
- 'rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq d3 0 1',
- 'd2d4',
- 'd4',
- )
-
- // Create variation from root: Nf3
- const nf3 = gameTree.addVariation(
- root,
- 'rnbqkbnr/pppppppp/8/8/8/5N2/PPPPPPPP/RNBQKB1R b KQkq - 1 1',
- 'g1f3',
- 'Nf3',
- )
-
- const simulateMakeMove = (currentNode: GameNode, move: string) => {
- const chess = new Chess(currentNode.fen)
- const moveAttempt = chess.move({
- from: move.slice(0, 2),
- to: move.slice(2, 4),
- promotion: move[4] ? (move[4] as PieceSymbol) : undefined,
- })
-
- if (moveAttempt) {
- const newFen = chess.fen()
- const moveString =
- moveAttempt.from + moveAttempt.to + (moveAttempt.promotion || '')
- const san = moveAttempt.san
-
- if (currentNode.mainChild?.move === moveString) {
- return { type: 'navigate', node: currentNode.mainChild }
- } else if (!currentNode.mainChild && currentNode.isMainline) {
- const newMainMove = gameTree.addMainMove(
- currentNode,
- newFen,
- moveString,
- san,
- )
- return { type: 'main_line', node: newMainMove }
- } else {
- const newVariation = gameTree.addVariation(
- currentNode,
- newFen,
- moveString,
- san,
- )
- return { type: 'variation', node: newVariation }
- }
- }
- return null
- }
-
- // Make move from e4 (main line) - should create main line continuation
- const e4Continue = simulateMakeMove(e4, 'e7e5') as {
- type: 'main_line'
- node: GameNode
- }
- expect(e4Continue).not.toBeNull()
- expect(e4Continue.type).toBe('main_line')
- expect(e4Continue.node.isMainline).toBe(true)
-
- // Make move from d4 (variation) - should create variation continuation
- const d4Continue = simulateMakeMove(d4, 'g8f6') as {
- type: 'variation'
- node: GameNode
- }
- expect(d4Continue.type).toBe('variation')
- expect(d4Continue.node.isMainline).toBe(false)
-
- // Make move from Nf3 (variation) - should create variation continuation
- const nf3Continue = simulateMakeMove(nf3, 'e7e5') as {
- type: 'variation'
- node: GameNode
- }
- expect(nf3Continue.type).toBe('variation')
- expect(nf3Continue.node.isMainline).toBe(false)
-
- // Verify tree structure
- expect(root.children).toHaveLength(3) // e4, d4, Nf3
- expect(e4.children).toHaveLength(1) // e5 (main line)
- expect(d4.children).toHaveLength(1) // Nf6 (variation)
- expect(nf3.children).toHaveLength(1) // e5 (variation)
-
- expect(e4.mainChild).toBe(e4Continue.node)
- expect(d4.mainChild).toBeNull() // variations don't have main children
- expect(nf3.mainChild).toBeNull() // variations don't have main children
- })
-})
diff --git a/__tests__/api/active-users.test.ts b/__tests__/api/active-users.test.ts
deleted file mode 100644
index 2564eff9..00000000
--- a/__tests__/api/active-users.test.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { createMocks } from 'node-mocks-http'
-import handler from 'src/pages/api/active-users'
-
-global.fetch = jest.fn()
-
-describe('/api/active-users', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- delete process.env.POSTHOG_PROJECT_ID
- delete process.env.POSTHOG_API_KEY
- })
-
- it('should return 405 for non-GET requests', async () => {
- const { req, res } = createMocks({
- method: 'POST',
- })
-
- await handler(req, res)
-
- expect(res._getStatusCode()).toBe(405)
- const data = JSON.parse(res._getData())
- expect(data.success).toBe(false)
- expect(data.error).toBe('Method not allowed')
- })
-})
diff --git a/__tests__/api/home/activeUsers.test.ts b/__tests__/api/home/activeUsers.test.ts
deleted file mode 100644
index 64cf8cb8..00000000
--- a/__tests__/api/home/activeUsers.test.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { getActiveUserCount } from 'src/api/home/activeUsers'
-
-// Mock fetch for API calls
-global.fetch = jest.fn()
-
-describe('getActiveUserCount', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('should return a positive number', async () => {
- // Mock successful API response
- ;(fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- activeUsers: 15,
- success: true,
- }),
- })
-
- const count = await getActiveUserCount()
- expect(count).toBeGreaterThanOrEqual(0)
- expect(Number.isInteger(count)).toBe(true)
- })
-
- it('should call the internal API endpoint', async () => {
- // Mock successful API response
- ;(fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- activeUsers: 10,
- success: true,
- }),
- })
-
- const count = await getActiveUserCount()
-
- expect(fetch).toHaveBeenCalledWith('/api/active-users')
- expect(count).toBe(10)
- })
-})
diff --git a/__tests__/components/Analysis/AnalyzeEntireGame.test.tsx b/__tests__/components/Analysis/AnalyzeEntireGame.test.tsx
deleted file mode 100644
index 85360618..00000000
--- a/__tests__/components/Analysis/AnalyzeEntireGame.test.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import React from 'react'
-import { render, screen } from '@testing-library/react'
-import { AnalysisConfigModal } from 'src/components/Analysis/AnalysisConfigModal'
-import { AnalysisNotification } from 'src/components/Analysis/AnalysisNotification'
-import '@testing-library/jest-dom'
-
-// Mock framer-motion to avoid animation issues in tests
-jest.mock('framer-motion', () => ({
- motion: {
- div: ({ children, ...props }: any) =>
{children}
,
- },
- AnimatePresence: ({ children }: any) => <>{children}>,
-}))
-
-describe('Analyze Entire Game Components', () => {
- describe('AnalysisConfigModal', () => {
- const defaultProps = {
- isOpen: true,
- onClose: jest.fn(),
- onConfirm: jest.fn(),
- initialDepth: 15,
- }
-
- it('renders the modal when open', () => {
- render( )
-
- expect(screen.getByText('Analyze Entire Game')).toBeInTheDocument()
- expect(
- screen.getByText(
- 'Choose the Stockfish analysis depth for all positions in the game:',
- ),
- ).toBeInTheDocument()
- })
-
- it('renders depth options', () => {
- render( )
-
- expect(screen.getByText('Fast (d12)')).toBeInTheDocument()
- expect(screen.getByText('Balanced (d15)')).toBeInTheDocument()
- expect(screen.getByText('Deep (d18)')).toBeInTheDocument()
- })
-
- it('renders start analysis button', () => {
- render( )
-
- expect(screen.getByText('Start Analysis')).toBeInTheDocument()
- expect(screen.getByText('Cancel')).toBeInTheDocument()
- })
-
- it('does not render when closed', () => {
- render( )
-
- expect(screen.queryByText('Analyze Entire Game')).not.toBeInTheDocument()
- })
- })
-
- describe('AnalysisNotification', () => {
- const mockProgress = {
- currentMoveIndex: 5,
- totalMoves: 20,
- currentMove: 'e4',
- isAnalyzing: true,
- isComplete: false,
- isCancelled: false,
- }
-
- const defaultProps = {
- progress: mockProgress,
- onCancel: jest.fn(),
- }
-
- it('renders notification when analyzing', () => {
- render( )
-
- expect(screen.getByText('Analyzing Game')).toBeInTheDocument()
- expect(screen.getByText('Position 5 of 20')).toBeInTheDocument()
- expect(screen.getByText('25%')).toBeInTheDocument()
- })
-
- it('renders current move being analyzed', () => {
- render( )
-
- expect(screen.getByText('Current:')).toBeInTheDocument()
- expect(screen.getByText('e4')).toBeInTheDocument()
- })
-
- it('renders cancel button', () => {
- render( )
-
- const cancelButton = screen.getByTitle('Cancel Analysis')
- expect(cancelButton).toBeInTheDocument()
- })
-
- it('does not render when not analyzing', () => {
- const notAnalyzingProgress = {
- ...mockProgress,
- isAnalyzing: false,
- }
-
- render(
- ,
- )
-
- expect(screen.queryByText('Analyzing Game')).not.toBeInTheDocument()
- })
- })
-})
diff --git a/__tests__/components/AnimatedNumber.test.tsx b/__tests__/components/AnimatedNumber.test.tsx
deleted file mode 100644
index c44b49e9..00000000
--- a/__tests__/components/AnimatedNumber.test.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import { AnimatedNumber } from '../../src/components/Common/AnimatedNumber'
-
-// Mock framer-motion to avoid complex animation testing
-let mockValue = 1000
-jest.mock('framer-motion', () => ({
- motion: {
- span: ({ children, className, ...props }: React.ComponentProps<'span'>) => (
-
- {children}
-
- ),
- },
- useSpring: jest.fn((value) => {
- mockValue = value
- return {
- set: jest.fn((newValue) => {
- mockValue = newValue
- }),
- get: jest.fn(() => mockValue),
- }
- }),
- useTransform: jest.fn((_, transform) => {
- return transform(mockValue)
- }),
-}))
-
-describe('AnimatedNumber Component', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('should render with default formatting', () => {
- render( )
-
- // The component should render the formatted value
- expect(screen.getByText('1,000')).toBeInTheDocument()
- })
-
- it('should apply custom className', () => {
- render( )
-
- const element = screen.getByText('1,000')
- expect(element).toHaveClass('custom-class')
- })
-
- it('should use custom formatValue function', () => {
- const customFormat = (value: number) => `$${value.toFixed(2)}`
- render( )
-
- expect(screen.getByText('$1000.00')).toBeInTheDocument()
- })
-
- it('should handle zero value', () => {
- render( )
-
- expect(screen.getByText('0')).toBeInTheDocument()
- })
-
- it('should handle negative values', () => {
- render( )
-
- expect(screen.getByText('-500')).toBeInTheDocument()
- })
-
- it('should handle decimal values with default rounding', () => {
- render( )
-
- expect(screen.getByText('1,235')).toBeInTheDocument()
- })
-
- it('should handle large numbers', () => {
- render( )
-
- expect(screen.getByText('1,000,000')).toBeInTheDocument()
- })
-
- it('should use custom duration prop', () => {
- const { rerender } = render( )
-
- // Test that component renders without error with custom duration
- expect(screen.getByText('1,000')).toBeInTheDocument()
-
- // Rerender with different value to test duration effect
- rerender( )
- expect(screen.getByText('2,000')).toBeInTheDocument()
- })
-
- it('should handle percentage formatting', () => {
- const percentFormat = (value: number) => `${(value * 100).toFixed(1)}%`
- render( )
-
- expect(screen.getByText('85.0%')).toBeInTheDocument()
- })
-
- it('should handle currency formatting', () => {
- const currencyFormat = (value: number) =>
- new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: 'USD',
- }).format(value)
-
- render( )
-
- expect(screen.getByText('$1,234.56')).toBeInTheDocument()
- })
-
- it('should render as motion.span element', () => {
- render( )
-
- const element = screen.getByText('1,000')
- expect(element.tagName).toBe('SPAN')
- })
-})
diff --git a/__tests__/components/AuthenticatedWrapper.test.tsx b/__tests__/components/AuthenticatedWrapper.test.tsx
deleted file mode 100644
index 22d8fc30..00000000
--- a/__tests__/components/AuthenticatedWrapper.test.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import { AuthenticatedWrapper } from '../../src/components/Common/AuthenticatedWrapper'
-import { AuthContext } from '../../src/contexts/AuthContext'
-import { User } from '../../src/types/auth'
-
-const mockUser: User = {
- clientId: 'test-client-id',
- displayName: 'TestUser',
- lichessId: 'testuser',
-}
-
-const AuthProvider = ({
- user,
- children,
-}: {
- user: User | null
- children: React.ReactNode
-}) => (
-
- {children}
-
-)
-
-describe('AuthenticatedWrapper Component', () => {
- it('should render children when user is authenticated', () => {
- render(
-
-
- Protected content
-
- ,
- )
-
- expect(screen.getByText('Protected content')).toBeInTheDocument()
- })
-
- it('should not render children when user is not authenticated', () => {
- render(
-
-
- Protected content
-
- ,
- )
-
- expect(screen.queryByText('Protected content')).not.toBeInTheDocument()
- })
-
- it('should handle multiple children when user is authenticated', () => {
- render(
-
-
- First child
- Second child
- Third child
-
- ,
- )
-
- expect(screen.getByText('First child')).toBeInTheDocument()
- expect(screen.getByText('Second child')).toBeInTheDocument()
- expect(screen.getByText('Third child')).toBeInTheDocument()
- })
-
- it('should not render multiple children when user is not authenticated', () => {
- render(
-
-
- First child
- Second child
- Third child
-
- ,
- )
-
- expect(screen.queryByText('First child')).not.toBeInTheDocument()
- expect(screen.queryByText('Second child')).not.toBeInTheDocument()
- expect(screen.queryByText('Third child')).not.toBeInTheDocument()
- })
-
- it('should handle no children gracefully when user is authenticated', () => {
- render(
-
-
- ,
- )
-
- // Should not crash and should render empty fragment
- expect(screen.queryByText(/./)).not.toBeInTheDocument()
- })
-
- it('should handle no children gracefully when user is not authenticated', () => {
- render(
-
-
- ,
- )
-
- // Should not crash and should render empty fragment
- expect(screen.queryByText(/./)).not.toBeInTheDocument()
- })
-
- it('should re-render when authentication state changes', () => {
- const { rerender } = render(
-
-
- Protected content
-
- ,
- )
-
- // Initially not authenticated
- expect(screen.queryByText('Protected content')).not.toBeInTheDocument()
-
- // Re-render with authenticated user
- rerender(
-
-
- Protected content
-
- ,
- )
-
- expect(screen.getByText('Protected content')).toBeInTheDocument()
-
- // Re-render back to unauthenticated
- rerender(
-
-
- Protected content
-
- ,
- )
-
- expect(screen.queryByText('Protected content')).not.toBeInTheDocument()
- })
-})
diff --git a/__tests__/components/Compose.test.tsx b/__tests__/components/Compose.test.tsx
deleted file mode 100644
index 177f40b5..00000000
--- a/__tests__/components/Compose.test.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import { Compose } from '../../src/components/Common/Compose'
-import { ErrorBoundary } from '../../src/components/Common/ErrorBoundary'
-
-// Mock ErrorBoundary to avoid chessground import issues
-jest.mock('../../src/components/Common/ErrorBoundary', () => ({
- ErrorBoundary: ({ children }: { children: React.ReactNode }) => (
- {children}
- ),
-}))
-
-// Mock providers for testing
-const MockProvider1 = ({ children }: { children: React.ReactNode }) => (
- {children}
-)
-
-const MockProvider2 = ({ children }: { children: React.ReactNode }) => (
- {children}
-)
-
-const MockProvider3 = ({ children }: { children: React.ReactNode }) => (
- {children}
-)
-
-describe('Compose Component', () => {
- it('should render children with single component', () => {
- render(
-
- Test Child
- ,
- )
-
- expect(screen.getByTestId('provider-1')).toBeInTheDocument()
- expect(screen.getByTestId('child')).toBeInTheDocument()
- expect(screen.getByText('Test Child')).toBeInTheDocument()
- })
-
- it('should nest multiple components correctly', () => {
- render(
-
- Nested Child
- ,
- )
-
- expect(screen.getByTestId('provider-1')).toBeInTheDocument()
- expect(screen.getByTestId('provider-2')).toBeInTheDocument()
- expect(screen.getByTestId('child')).toBeInTheDocument()
-
- // Verify nesting order
- const provider1 = screen.getByTestId('provider-1')
- const provider2 = screen.getByTestId('provider-2')
- expect(provider1).toContainElement(provider2)
- })
-
- it('should handle three levels of nesting', () => {
- render(
-
- Deep Nested Child
- ,
- )
-
- expect(screen.getByTestId('provider-1')).toBeInTheDocument()
- expect(screen.getByTestId('provider-2')).toBeInTheDocument()
- expect(screen.getByTestId('provider-3')).toBeInTheDocument()
- expect(screen.getByTestId('child')).toBeInTheDocument()
-
- // Verify deep nesting
- const provider1 = screen.getByTestId('provider-1')
- const provider2 = screen.getByTestId('provider-2')
- const provider3 = screen.getByTestId('provider-3')
- expect(provider1).toContainElement(provider2)
- expect(provider2).toContainElement(provider3)
- })
-
- it('should work with ErrorBoundary component', () => {
- render(
-
- Error Wrapped Child
- ,
- )
-
- expect(screen.getByTestId('error-boundary')).toBeInTheDocument()
- expect(screen.getByTestId('provider-1')).toBeInTheDocument()
- expect(screen.getByTestId('child')).toBeInTheDocument()
- })
-
- it('should handle empty components array', () => {
- render(
-
- Unwrapped Child
- ,
- )
-
- expect(screen.getByTestId('child')).toBeInTheDocument()
- expect(screen.getByText('Unwrapped Child')).toBeInTheDocument()
- })
-
- it('should render multiple children', () => {
- render(
-
- First Child
- Second Child
- ,
- )
-
- expect(screen.getByTestId('provider-1')).toBeInTheDocument()
- expect(screen.getByTestId('child-1')).toBeInTheDocument()
- expect(screen.getByTestId('child-2')).toBeInTheDocument()
- expect(screen.getByText('First Child')).toBeInTheDocument()
- expect(screen.getByText('Second Child')).toBeInTheDocument()
- })
-
- it('should preserve React node types', () => {
- render(
-
- Text node
- Button node
-
- ,
- )
-
- expect(screen.getByText('Text node')).toBeInTheDocument()
- expect(
- screen.getByRole('button', { name: 'Button node' }),
- ).toBeInTheDocument()
- expect(screen.getByPlaceholderText('Input node')).toBeInTheDocument()
- })
-})
diff --git a/__tests__/components/DelayedLoading.test.tsx b/__tests__/components/DelayedLoading.test.tsx
deleted file mode 100644
index e4a99f16..00000000
--- a/__tests__/components/DelayedLoading.test.tsx
+++ /dev/null
@@ -1,210 +0,0 @@
-import { render, screen, waitFor, act } from '@testing-library/react'
-import { DelayedLoading } from '../../src/components/Common/DelayedLoading'
-
-// Mock the Loading component
-jest.mock('../../src/components/Common/Loading', () => ({
- Loading: () => Loading...
,
-}))
-
-// Mock framer-motion
-jest.mock('framer-motion', () => ({
- motion: {
- div: ({ children, ...props }: React.ComponentProps<'div'>) => (
- {children}
- ),
- },
- AnimatePresence: ({ children }: { children: React.ReactNode }) => (
- <>{children}>
- ),
-}))
-
-describe('DelayedLoading Component', () => {
- beforeEach(() => {
- jest.clearAllTimers()
- jest.useFakeTimers()
- })
-
- afterEach(() => {
- jest.useRealTimers()
- })
-
- it('should render children immediately when not loading', () => {
- render(
-
- Main content
- ,
- )
-
- expect(screen.getByTestId('content')).toBeInTheDocument()
- expect(screen.getByText('Main content')).toBeInTheDocument()
- expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
- })
-
- it('should not show loading immediately when isLoading is true', () => {
- render(
-
- Main content
- ,
- )
-
- expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
- expect(screen.queryByTestId('content')).not.toBeInTheDocument()
- })
-
- it('should show loading after default delay (1000ms)', async () => {
- render(
-
- Main content
- ,
- )
-
- // Before delay
- expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
-
- // Advance time by 1000ms
- act(() => {
- jest.advanceTimersByTime(1000)
- })
-
- expect(screen.getByTestId('loading-component')).toBeInTheDocument()
- expect(screen.queryByTestId('content')).not.toBeInTheDocument()
- })
-
- it('should show loading after custom delay', async () => {
- render(
-
- Main content
- ,
- )
-
- // Before delay
- expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
-
- // Advance time by 500ms
- act(() => {
- jest.advanceTimersByTime(500)
- })
-
- expect(screen.getByTestId('loading-component')).toBeInTheDocument()
- })
-
- it('should not show loading if isLoading becomes false before delay', () => {
- const { rerender } = render(
-
- Main content
- ,
- )
-
- // Advance time by 500ms (less than delay)
- act(() => {
- jest.advanceTimersByTime(500)
- })
-
- // Set isLoading to false before delay completes
- rerender(
-
- Main content
- ,
- )
-
- // Complete the remaining time
- act(() => {
- jest.advanceTimersByTime(500)
- })
-
- expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
- expect(screen.getByTestId('content')).toBeInTheDocument()
- })
-
- it('should hide loading and show content when isLoading becomes false', () => {
- const { rerender } = render(
-
- Main content
- ,
- )
-
- // Wait for loading to show
- act(() => {
- jest.advanceTimersByTime(500)
- })
-
- expect(screen.getByTestId('loading-component')).toBeInTheDocument()
-
- // Set isLoading to false
- rerender(
-
- Main content
- ,
- )
-
- expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
- expect(screen.getByTestId('content')).toBeInTheDocument()
- })
-
- it('should handle delay prop changes', () => {
- const { rerender } = render(
-
- Main content
- ,
- )
-
- // Change delay
- rerender(
-
- Main content
- ,
- )
-
- // Advance by the new delay amount
- act(() => {
- jest.advanceTimersByTime(200)
- })
-
- expect(screen.getByTestId('loading-component')).toBeInTheDocument()
- })
-
- it('should clean up timer on unmount', () => {
- const { unmount } = render(
-
- Main content
- ,
- )
-
- const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout')
-
- unmount()
-
- expect(clearTimeoutSpy).toHaveBeenCalled()
- clearTimeoutSpy.mockRestore()
- })
-
- it('should handle multiple children', () => {
- render(
-
- First child
- Second child
- ,
- )
-
- expect(screen.getByTestId('child1')).toBeInTheDocument()
- expect(screen.getByTestId('child2')).toBeInTheDocument()
- expect(screen.getByText('First child')).toBeInTheDocument()
- expect(screen.getByText('Second child')).toBeInTheDocument()
- })
-
- it('should apply correct CSS classes and motion props', () => {
- render(
-
- Main content
- ,
- )
-
- act(() => {
- jest.advanceTimersByTime(100)
- })
-
- const loadingContainer =
- screen.getByTestId('loading-component').parentElement
- expect(loadingContainer).toHaveClass('my-auto')
- })
-})
diff --git a/__tests__/components/GameInfo.test.tsx b/__tests__/components/GameInfo.test.tsx
deleted file mode 100644
index 3b87607a..00000000
--- a/__tests__/components/GameInfo.test.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-import { render, screen, fireEvent } from '@testing-library/react'
-import { GameInfo } from '../../src/components/Common/GameInfo'
-import { InstructionsType } from '../../src/types'
-
-// Mock the tour context
-const mockStartTour = jest.fn()
-jest.mock('../../src/contexts/TourContext/TourContext', () => ({
- useTour: () => ({
- startTour: mockStartTour,
- }),
-}))
-
-// Mock the tour configs
-jest.mock('../../src/constants/tours', () => ({
- tourConfigs: {
- analysis: {
- steps: [],
- },
- },
-}))
-
-const defaultProps = {
- icon: 'analytics',
- title: 'Test Analysis',
- type: 'analysis' as InstructionsType,
- children: Test content
,
-}
-
-const MOCK_MAIA_MODELS = ['maia_kdd_1100', 'maia_kdd_1500', 'maia_kdd_1900']
-
-describe('GameInfo Component', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('should render basic props correctly', () => {
- render( )
-
- expect(screen.getByText('analytics')).toBeInTheDocument()
- expect(screen.getByText('Test Analysis')).toBeInTheDocument()
- expect(screen.getByText('Test content')).toBeInTheDocument()
- })
-
- it('should render with correct icon class', () => {
- render( )
-
- const iconElement = screen.getByText('analytics')
- expect(iconElement).toHaveClass('material-symbols-outlined')
- expect(iconElement).toHaveClass('text-lg', 'md:text-xl')
- })
-
- it('should call setCurrentMaiaModel when model is changed', () => {
- const mockSetCurrentMaiaModel = jest.fn()
-
- render(
- ,
- )
-
- const selectElement = screen.getByDisplayValue('Maia 1500')
- fireEvent.change(selectElement, { target: { value: 'maia_kdd_1900' } })
-
- expect(mockSetCurrentMaiaModel).toHaveBeenCalledWith('maia_kdd_1900')
- })
-
- it('should not render Maia model selector when currentMaiaModel is not provided', () => {
- render( )
-
- expect(screen.queryByText('using')).not.toBeInTheDocument()
- })
-
- it('should render game list button when showGameListButton is true', () => {
- const mockOnGameListClick = jest.fn()
-
- render(
- ,
- )
-
- const gameListButton = screen.getByText('Switch Game')
- expect(gameListButton).toBeInTheDocument()
- })
-
- it('should call onGameListClick when game list button is clicked', () => {
- const mockOnGameListClick = jest.fn()
-
- render(
- ,
- )
-
- const gameListButton = screen.getByText('Switch Game')
- fireEvent.click(gameListButton)
-
- expect(mockOnGameListClick).toHaveBeenCalledTimes(1)
- })
-
- it('should have correct container structure and classes', () => {
- const { container } = render( )
-
- const mainContainer = container.firstChild
- expect(mainContainer).toHaveClass(
- 'flex',
- 'w-full',
- 'flex-col',
- 'items-start',
- 'justify-start',
- 'gap-1',
- 'overflow-hidden',
- 'bg-background-1',
- 'p-1.5',
- 'md:rounded',
- 'md:p-3',
- )
- expect(mainContainer).toHaveAttribute('id', 'analysis-game-list')
- })
-})
diff --git a/__tests__/components/GameList.test.tsx b/__tests__/components/GameList.test.tsx
deleted file mode 100644
index e3c1c703..00000000
--- a/__tests__/components/GameList.test.tsx
+++ /dev/null
@@ -1,264 +0,0 @@
-import React from 'react'
-import { render, screen, waitFor, act } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { GameList } from 'src/components/Profile/GameList'
-import { AuthContext } from 'src/contexts'
-import * as api from 'src/api'
-
-// Mock the API functions
-jest.mock('src/api', () => ({
- getAnalysisGameList: jest.fn(),
- getLichessGames: jest.fn(),
-}))
-
-// Mock custom analysis utility
-jest.mock('src/lib/customAnalysis', () => ({
- getCustomAnalysesAsWebGames: jest.fn(() => []),
-}))
-
-// Mock favorites utility
-jest.mock('src/lib/favorites', () => ({
- getFavoritesAsWebGames: jest.fn(() => []),
- addFavoriteGame: jest.fn(),
- removeFavoriteGame: jest.fn(),
- isFavoriteGame: jest.fn(() => false),
-}))
-
-// Mock FavoriteModal component
-jest.mock('src/components/Common/FavoriteModal', () => ({
- FavoriteModal: () => null,
-}))
-
-// Mock framer-motion to avoid animation issues in tests
-jest.mock('framer-motion', () => ({
- motion: {
- div: ({
- children,
- layoutId,
- ...props
- }: React.PropsWithChildren<{ layoutId?: string }>) => (
- {children}
- ),
- },
-}))
-
-const mockGetAnalysisGameList = api.getAnalysisGameList as jest.MockedFunction<
- typeof api.getAnalysisGameList
->
-
-const mockGetLichessGames = api.getLichessGames as jest.MockedFunction<
- typeof api.getLichessGames
->
-
-// Mock user context
-const mockUser = {
- clientId: 'client123',
- displayName: 'Test User',
- lichessId: 'testuser123',
- id: 'user123',
-}
-
-const AuthWrapper = ({ children }: { children: React.ReactNode }) => (
-
- {children}
-
-)
-
-describe('GameList', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- // Mock different responses based on game type
- mockGetAnalysisGameList.mockImplementation((gameType) => {
- if (gameType === 'hand') {
- return Promise.resolve({
- games: [
- {
- game_id: 'game1',
- maia_name: 'maia_kdd_1500',
- result: '1-0',
- player_color: 'white',
- },
- ],
- total_games: 1,
- total_pages: 1,
- })
- } else if (gameType === 'brain') {
- return Promise.resolve({
- games: [],
- total_games: 0,
- total_pages: 0,
- })
- }
- // Default for 'play' and other types
- return Promise.resolve({
- games: [
- {
- game_id: 'game1',
- maia_name: 'maia_kdd_1500',
- result: '1-0',
- player_color: 'white',
- },
- ],
- total_games: 1,
- total_pages: 1,
- })
- })
- })
-
- it('renders with default props (all tabs shown for current user)', async () => {
- await act(async () => {
- render(
-
-
- ,
- )
- })
-
- expect(screen.getByText('Your Games')).toBeInTheDocument()
- expect(screen.getByText('Play')).toBeInTheDocument()
- expect(screen.getByText('H&B')).toBeInTheDocument()
- expect(screen.getByText('Custom')).toBeInTheDocument()
- expect(screen.getByText('Lichess')).toBeInTheDocument()
- })
-
- it('renders with limited tabs for other users', async () => {
- await act(async () => {
- render(
-
-
- ,
- )
- })
-
- expect(screen.getByText("OtherUser's Games")).toBeInTheDocument()
- expect(screen.getByText('Play')).toBeInTheDocument()
- expect(screen.getByText('H&B')).toBeInTheDocument()
- expect(screen.queryByText('Custom')).not.toBeInTheDocument()
- expect(screen.queryByText('Lichess')).not.toBeInTheDocument()
- })
-
- it('fetches games with lichessId when provided', async () => {
- const user = userEvent.setup()
-
- await act(async () => {
- render(
-
-
- ,
- )
- })
-
- // Click on Play tab to trigger API call
- await act(async () => {
- await user.click(screen.getByText('Play'))
- })
-
- await waitFor(() => {
- expect(mockGetAnalysisGameList).toHaveBeenCalledWith(
- 'play',
- 1,
- 'otheruser',
- )
- })
- })
-
- it('displays correct game labels for other users', async () => {
- const user = userEvent.setup()
-
- await act(async () => {
- render(
-
-
- ,
- )
- })
-
- // Click on Play tab to see games
- await act(async () => {
- await user.click(screen.getByText('Play'))
- })
-
- await waitFor(() => {
- expect(screen.getByText('OtherUser vs. Maia 1500')).toBeInTheDocument()
- })
- })
-
- it('displays correct game labels for current user', async () => {
- const user = userEvent.setup()
-
- await act(async () => {
- render(
-
-
- ,
- )
- })
-
- // Click on Play tab to see games
- await act(async () => {
- await user.click(screen.getByText('Play'))
- })
-
- await waitFor(() => {
- expect(screen.getByText('You vs. Maia 1500')).toBeInTheDocument()
- })
- })
-
- it('switches between H&B subsections', async () => {
- const user = userEvent.setup()
-
- await act(async () => {
- render(
-
-
- ,
- )
- })
-
- // Click on H&B tab
- await act(async () => {
- await user.click(screen.getByText('H&B'))
- })
-
- // Wait for the hand games to load and check subsection labels
- await waitFor(() => {
- expect(screen.getByText('Hand')).toBeInTheDocument()
- expect(screen.getByText('Brain')).toBeInTheDocument()
- })
-
- // Click on Brain subsection
- await act(async () => {
- await user.click(screen.getByText('Brain'))
- })
-
- // Verify API call for brain games
- await waitFor(() => {
- expect(mockGetAnalysisGameList).toHaveBeenCalledWith(
- 'brain',
- 1,
- undefined,
- )
- })
- })
-})
diff --git a/__tests__/components/Icons.test.tsx b/__tests__/components/Icons.test.tsx
deleted file mode 100644
index cabc36b7..00000000
--- a/__tests__/components/Icons.test.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import {
- RegularPlayIcon,
- BrainIcon,
- BotOrNotIcon,
- TrainIcon,
- HandIcon,
- StarIcon,
- ChessboardIcon,
- GithubIcon,
- DiscordIcon,
- FlipIcon,
-} from '../../src/components/Common/Icons'
-
-describe('Icons Component', () => {
- describe('SVG Icons', () => {
- it('should render RegularPlayIcon with correct src and alt', () => {
- render( )
- const icon = screen.getByAltText('Regular Play Icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('src', '/assets/icons/regular_play_icon.svg')
- })
-
- it('should render BrainIcon with correct src and alt', () => {
- render( )
- const icon = screen.getByAltText('Brain Icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('src', '/assets/icons/brain_icon.svg')
- })
-
- it('should render BotOrNotIcon with correct src and alt', () => {
- render( )
- const icon = screen.getByAltText('Bot-or-Not Icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('src', '/assets/icons/turing_icon.svg')
- })
-
- it('should render TrainIcon with correct src and alt', () => {
- render( )
- const icon = screen.getByAltText('Train Icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('src', '/assets/icons/train_icon.svg')
- })
-
- it('should render HandIcon with correct src and alt', () => {
- render( )
- const icon = screen.getByAltText('Hand Icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('src', '/assets/icons/hand_icon.svg')
- })
-
- it('should render StarIcon with correct src and alt', () => {
- render( )
- const icon = screen.getByAltText('Star Icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('src', '/assets/icons/star_icon.svg')
- })
-
- it('should render ChessboardIcon with correct src and alt', () => {
- render( )
- const icon = screen.getByAltText('Chessboard Icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('src', '/assets/icons/chessboard_icon.svg')
- })
- })
-
- describe('SVG Component Icons', () => {
- it('should render GithubIcon as SVG element', () => {
- const { container } = render({GithubIcon}
)
- const svg = container.querySelector('svg')
- expect(svg).toBeInTheDocument()
- expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg')
- expect(svg).toHaveAttribute('height', '1em')
- expect(svg).toHaveAttribute('viewBox', '0 0 496 512')
- })
-
- it('should render DiscordIcon as SVG element', () => {
- const { container } = render({DiscordIcon}
)
- const svg = container.querySelector('svg')
- expect(svg).toBeInTheDocument()
- expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg')
- expect(svg).toHaveAttribute('viewBox', '0 0 127.14 96.36')
- })
-
- it('should render FlipIcon as SVG element', () => {
- const { container } = render({FlipIcon}
)
- const svg = container.querySelector('svg')
- expect(svg).toBeInTheDocument()
- expect(svg).toHaveAttribute('fill', 'white')
- expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg')
- expect(svg).toHaveAttribute('viewBox', '1 1 22 22')
- expect(svg).toHaveAttribute('width', '14px')
- expect(svg).toHaveAttribute('height', '14px')
- })
- })
-
- describe('Icon accessibility', () => {
- it('should have alt text for all image icons', () => {
- const imageIcons = [
- { component: , alt: 'Regular Play Icon' },
- { component: , alt: 'Brain Icon' },
- { component: , alt: 'Bot-or-Not Icon' },
- { component: , alt: 'Train Icon' },
- { component: , alt: 'Hand Icon' },
- { component: , alt: 'Star Icon' },
- { component: , alt: 'Chessboard Icon' },
- ]
-
- imageIcons.forEach(({ component, alt }) => {
- render(component)
- expect(screen.getByAltText(alt)).toBeInTheDocument()
- })
- })
- })
-})
diff --git a/__tests__/components/PlayerInfo.test.tsx b/__tests__/components/PlayerInfo.test.tsx
deleted file mode 100644
index 3e21a2c9..00000000
--- a/__tests__/components/PlayerInfo.test.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import { PlayerInfo } from '../../src/components/Common/PlayerInfo'
-
-const defaultProps = {
- name: 'TestPlayer',
- color: 'white',
-}
-
-describe('PlayerInfo Component', () => {
- it('should render player name correctly', () => {
- render( )
-
- expect(screen.getByText('TestPlayer')).toBeInTheDocument()
- })
-
- it('should render player rating when provided', () => {
- render( )
-
- expect(screen.getByText('TestPlayer (1500)')).toBeInTheDocument()
- })
-
- it('should not render rating when not provided', () => {
- render( )
-
- expect(screen.getByText('TestPlayer')).toBeInTheDocument()
- expect(screen.queryByText(/\(.*\)/)).not.toBeInTheDocument()
- })
-
- it('should render "Unknown" when name is not provided', () => {
- render( )
-
- expect(screen.getByText('Unknown')).toBeInTheDocument()
- })
-
- it('should render empty string when name is empty', () => {
- render( )
-
- // Empty string should render as-is, not as "Unknown"
- expect(screen.queryByText('Unknown')).not.toBeInTheDocument()
- })
-
- it('should render white color indicator correctly', () => {
- const { container } = render( )
-
- const colorIndicator = container.querySelector('.bg-white')
- expect(colorIndicator).toBeInTheDocument()
- expect(colorIndicator).toHaveClass('h-2.5', 'w-2.5', 'rounded-full')
- })
-
- it('should render black color indicator correctly', () => {
- const { container } = render( )
-
- const colorIndicator = container.querySelector('.bg-black')
- expect(colorIndicator).toBeInTheDocument()
- expect(colorIndicator).toHaveClass(
- 'h-2.5',
- 'w-2.5',
- 'rounded-full',
- 'border',
- )
- })
-
- describe('Arrow Legend', () => {
- it('should render arrow legend when showArrowLegend is true', () => {
- render( )
-
- expect(screen.getByText('Most Human Move')).toBeInTheDocument()
- expect(screen.getByText('Best Engine Move')).toBeInTheDocument()
- })
-
- it('should not render arrow legend when showArrowLegend is false', () => {
- render( )
-
- expect(screen.queryByText('Most Human Move')).not.toBeInTheDocument()
- expect(screen.queryByText('Best Engine Move')).not.toBeInTheDocument()
- })
-
- it('should not render arrow legend by default', () => {
- render( )
-
- expect(screen.queryByText('Most Human Move')).not.toBeInTheDocument()
- expect(screen.queryByText('Best Engine Move')).not.toBeInTheDocument()
- })
-
- it('should render arrow icons with correct classes in legend', () => {
- render( )
-
- const arrowIcons = screen.getAllByText('arrow_outward')
- expect(arrowIcons).toHaveLength(2)
-
- // Human move arrow
- expect(arrowIcons[0]).toHaveClass(
- 'material-symbols-outlined',
- '!text-xxs',
- 'text-human-3',
- )
-
- // Engine move arrow
- expect(arrowIcons[1]).toHaveClass(
- 'material-symbols-outlined',
- '!text-xxs',
- 'text-engine-3',
- )
- })
- })
-
- describe('Game Termination', () => {
- it('should show "1" when player won (termination matches color)', () => {
- render( )
-
- expect(screen.getByText('1')).toBeInTheDocument()
- expect(screen.getByText('1')).toHaveClass('text-engine-3')
- })
-
- it('should show "0" when player lost (termination does not match color and is not "none")', () => {
- render( )
-
- expect(screen.getByText('0')).toBeInTheDocument()
- expect(screen.getByText('0')).toHaveClass('text-human-3')
- })
-
- it('should show "½" when game was a draw (termination is "none")', () => {
- render( )
-
- expect(screen.getByText('½')).toBeInTheDocument()
- expect(screen.getByText('½')).toHaveClass('text-secondary')
- })
-
- it('should show nothing when termination is undefined', () => {
- render( )
-
- expect(screen.queryByText('1')).not.toBeInTheDocument()
- expect(screen.queryByText('0')).not.toBeInTheDocument()
- expect(screen.queryByText('½')).not.toBeInTheDocument()
- })
- })
-
- it('should have correct container structure and classes', () => {
- const { container } = render( )
-
- const mainContainer = container.firstChild
- expect(mainContainer).toHaveClass(
- 'flex',
- 'h-10',
- 'w-full',
- 'items-center',
- 'justify-between',
- 'bg-background-1',
- 'px-4',
- )
- })
-})
diff --git a/__tests__/components/Settings/SoundSettings.test.tsx b/__tests__/components/Settings/SoundSettings.test.tsx
deleted file mode 100644
index 0e1b4a43..00000000
--- a/__tests__/components/Settings/SoundSettings.test.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import '@testing-library/jest-dom'
-import { render, screen, fireEvent } from '@testing-library/react'
-import { SoundSettings } from 'src/components/Settings/SoundSettings'
-import { SettingsProvider } from 'src/contexts/SettingsContext'
-import { chessSoundManager } from 'src/lib/chessSoundManager'
-
-// Mock the chess sound manager
-jest.mock('src/lib/chessSoundManager', () => ({
- chessSoundManager: {
- playMoveSound: jest.fn(),
- },
- useChessSoundManager: () => ({
- playMoveSound: jest.fn(),
- ready: true,
- }),
-}))
-
-// Mock localStorage
-const localStorageMock = {
- getItem: jest.fn(),
- setItem: jest.fn(),
- removeItem: jest.fn(),
- clear: jest.fn(),
-}
-Object.defineProperty(window, 'localStorage', {
- value: localStorageMock,
-})
-
-describe('SoundSettings Component', () => {
- beforeEach(() => {
- localStorageMock.getItem.mockReturnValue(
- JSON.stringify({ soundEnabled: true, chessboardTheme: 'brown' }),
- )
- })
-
- afterEach(() => {
- jest.clearAllMocks()
- })
-
- it('renders sound settings with toggle enabled by default', () => {
- render(
-
-
- ,
- )
-
- expect(screen.getByText('Sound Settings')).toBeInTheDocument()
- expect(screen.getByText('Enable Move Sounds')).toBeInTheDocument()
- expect(screen.getByRole('checkbox')).toBeChecked()
- })
-
- it('shows test buttons when sound is enabled', () => {
- render(
-
-
- ,
- )
-
- expect(screen.getByText('Move Sound')).toBeInTheDocument()
- expect(screen.getByText('Capture Sound')).toBeInTheDocument()
- })
-
- it('saves settings to localStorage when toggle is changed', () => {
- render(
-
-
- ,
- )
-
- const checkbox = screen.getByRole('checkbox')
- fireEvent.click(checkbox)
-
- expect(localStorageMock.setItem).toHaveBeenCalledWith(
- 'maia-user-settings',
- JSON.stringify({ soundEnabled: false, chessboardTheme: 'brown' }),
- )
- })
-})
diff --git a/__tests__/components/StatsDisplay.test.tsx b/__tests__/components/StatsDisplay.test.tsx
deleted file mode 100644
index 354afe98..00000000
--- a/__tests__/components/StatsDisplay.test.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import { StatsDisplay } from '../../src/components/Common/StatsDisplay'
-import { AllStats } from '../../src/hooks/useStats'
-
-// Mock stats data
-const mockStats: AllStats = {
- rating: 1500,
- lastRating: 1450,
- session: {
- gamesWon: 3,
- gamesPlayed: 5,
- },
- lifetime: {
- gamesWon: 100,
- gamesPlayed: 150,
- },
-}
-
-const mockStatsNoRating: AllStats = {
- rating: undefined,
- lastRating: undefined,
- session: {
- gamesWon: 0,
- gamesPlayed: 0,
- },
- lifetime: {
- gamesWon: 0,
- gamesPlayed: 0,
- },
-}
-
-describe('StatsDisplay Component', () => {
- it('should render rating display', () => {
- render( )
-
- expect(screen.getByText('Your rating')).toBeInTheDocument()
- expect(screen.getByText('1500')).toBeInTheDocument()
- })
-
- it('should render rating difference for positive change', () => {
- render( )
-
- // Rating diff should be +50 (1500 - 1450)
- expect(screen.getByText('+50')).toBeInTheDocument()
- expect(screen.getByText('arrow_drop_up')).toBeInTheDocument()
- })
-
- it('should render rating difference for negative change', () => {
- const statsWithNegativeDiff = {
- ...mockStats,
- rating: 1400,
- lastRating: 1450,
- }
-
- render( )
-
- // Rating diff should be -50 (1400 - 1450), displayed as –50
- expect(screen.getByText('–50')).toBeInTheDocument()
- expect(screen.getByText('arrow_drop_down')).toBeInTheDocument()
- })
-
- it('should render session stats', () => {
- render( )
-
- expect(screen.getByText('This session')).toBeInTheDocument()
- expect(screen.getByText('3')).toBeInTheDocument() // gamesWon
- expect(screen.getByText('5')).toBeInTheDocument() // gamesPlayed
- expect(screen.getByText('60%')).toBeInTheDocument() // win rate
- })
-
- it('should render lifetime stats', () => {
- render( )
-
- expect(screen.getByText('Lifetime')).toBeInTheDocument()
- expect(screen.getByText('100')).toBeInTheDocument() // gamesWon
- expect(screen.getByText('150')).toBeInTheDocument() // gamesPlayed
- expect(screen.getByText('66%')).toBeInTheDocument() // win rate (100/150 = 66.67%, truncated to 66)
- })
-
- it('should hide session when hideSession prop is true', () => {
- render( )
-
- expect(screen.queryByText('This session')).not.toBeInTheDocument()
- expect(screen.getByText('Lifetime')).toBeInTheDocument()
- })
-
- it('should show "Wins" label when isGame is true', () => {
- render( )
-
- expect(screen.getAllByText('Wins')).toHaveLength(2) // Session and Lifetime
- })
-
- it('should show "Correct" label when isGame is false', () => {
- render( )
-
- expect(screen.getAllByText('Correct')).toHaveLength(2) // Session and Lifetime
- })
-
- it('should handle undefined stats gracefully', () => {
- render( )
-
- expect(screen.getAllByText('0')).toHaveLength(5) // Rating, wins, played (session & lifetime)
- expect(screen.getAllByText('-%')).toHaveLength(2) // Win rate for 0/0 should be NaN, displayed as '-' for session and lifetime
- })
-
- it('should handle NaN win percentage', () => {
- const statsWithNaN = {
- ...mockStats,
- session: {
- gamesWon: 0,
- gamesPlayed: 0,
- },
- }
-
- render( )
-
- expect(screen.getByText('-%')).toBeInTheDocument() // NaN should display as '-%'
- })
-
- it('should apply correct CSS classes', () => {
- render( )
-
- const container = screen
- .getByText('Your rating')
- .closest('div')?.parentElement
- expect(container).toHaveClass('flex', 'flex-col')
- // Additional specific classes can be tested based on actual implementation
- })
-
- it('should handle zero win percentage correctly', () => {
- const statsWithZeroWins = {
- ...mockStats,
- session: {
- gamesWon: 0,
- gamesPlayed: 10,
- },
- }
-
- render( )
-
- expect(screen.getByText('0%')).toBeInTheDocument()
- })
-
- it('should handle 100% win percentage correctly', () => {
- const statsWithAllWins = {
- ...mockStats,
- session: {
- gamesWon: 10,
- gamesPlayed: 10,
- },
- }
-
- render( )
-
- expect(screen.getByText('100%')).toBeInTheDocument()
- })
-
- it('should render without rating difference when lastRating is undefined', () => {
- const statsWithoutLastRating = {
- ...mockStats,
- lastRating: undefined,
- }
-
- render( )
-
- expect(screen.queryByText('+50')).not.toBeInTheDocument()
- expect(screen.queryByText('arrow_drop_up')).not.toBeInTheDocument()
- })
-
- it('should render material icons correctly', () => {
- render( )
-
- const upArrow = screen.getByText('arrow_drop_up')
- expect(upArrow).toHaveClass(
- 'material-symbols-outlined',
- 'material-symbols-filled',
- 'text-2xl',
- )
- })
-})
diff --git a/__tests__/hooks/useLeaderboardStatus.test.ts b/__tests__/hooks/useLeaderboardStatus.test.ts
deleted file mode 100644
index eb0b441b..00000000
--- a/__tests__/hooks/useLeaderboardStatus.test.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { renderHook, waitFor } from '@testing-library/react'
-import { useLeaderboardStatus } from 'src/hooks/useLeaderboardStatus'
-import * as api from 'src/api'
-
-// Mock the API
-jest.mock('src/api', () => ({
- getLeaderboard: jest.fn(),
-}))
-
-const mockLeaderboardData = {
- play_leaders: [
- { display_name: 'TestPlayer1', elo: 1800 },
- { display_name: 'TestPlayer2', elo: 1750 },
- ],
- puzzles_leaders: [
- { display_name: 'TestPlayer1', elo: 1600 },
- { display_name: 'TestPlayer3', elo: 1550 },
- ],
- turing_leaders: [{ display_name: 'TestPlayer4', elo: 1400 }],
- hand_leaders: [{ display_name: 'TestPlayer1', elo: 1500 }],
- brain_leaders: [{ display_name: 'TestPlayer5', elo: 1300 }],
- last_updated: '2024-01-01T00:00:00',
-}
-
-describe('useLeaderboardStatus', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- ;(api.getLeaderboard as jest.Mock).mockResolvedValue(mockLeaderboardData)
- })
-
- it('should return correct status for player on multiple leaderboards', async () => {
- const { result } = renderHook(() => useLeaderboardStatus('TestPlayer1'))
-
- expect(result.current.loading).toBe(true)
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false)
- })
-
- expect(result.current.status.isOnLeaderboard).toBe(true)
- expect(result.current.status.totalLeaderboards).toBe(3)
- expect(result.current.status.positions).toHaveLength(3)
-
- // Check specific positions
- const regularPosition = result.current.status.positions.find(
- (p) => p.gameType === 'regular',
- )
- expect(regularPosition?.position).toBe(1)
- expect(regularPosition?.elo).toBe(1800)
-
- const trainPosition = result.current.status.positions.find(
- (p) => p.gameType === 'train',
- )
- expect(trainPosition?.position).toBe(1)
- expect(trainPosition?.elo).toBe(1600)
-
- const handPosition = result.current.status.positions.find(
- (p) => p.gameType === 'hand',
- )
- expect(handPosition?.position).toBe(1)
- expect(handPosition?.elo).toBe(1500)
- })
-
- it('should return correct status for player not on leaderboard', async () => {
- const { result } = renderHook(() =>
- useLeaderboardStatus('NonExistentPlayer'),
- )
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false)
- })
-
- expect(result.current.status.isOnLeaderboard).toBe(false)
- expect(result.current.status.totalLeaderboards).toBe(0)
- expect(result.current.status.positions).toHaveLength(0)
- })
-
- it('should return empty status when no displayName provided', async () => {
- const { result } = renderHook(() => useLeaderboardStatus(undefined))
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false)
- })
-
- expect(result.current.status.isOnLeaderboard).toBe(false)
- expect(result.current.status.totalLeaderboards).toBe(0)
- expect(result.current.status.positions).toHaveLength(0)
- expect(api.getLeaderboard).not.toHaveBeenCalled()
- })
-
- it('should handle API errors gracefully', async () => {
- ;(api.getLeaderboard as jest.Mock).mockRejectedValue(new Error('API Error'))
-
- const { result } = renderHook(() => useLeaderboardStatus('TestPlayer1'))
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false)
- })
-
- expect(result.current.status.isOnLeaderboard).toBe(false)
- expect(result.current.error).toBe('Failed to fetch leaderboard data')
- })
-})
diff --git a/__tests__/hooks/useLocalStorage.test.ts b/__tests__/hooks/useLocalStorage.test.ts
deleted file mode 100644
index 05de60e9..00000000
--- a/__tests__/hooks/useLocalStorage.test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { renderHook, act } from '@testing-library/react'
-import { useLocalStorage } from '../../src/hooks/useLocalStorage/useLocalStorage'
-
-// Mock localStorage
-const mockLocalStorage = {
- getItem: jest.fn(),
- setItem: jest.fn(),
- removeItem: jest.fn(),
- clear: jest.fn(),
-}
-
-Object.defineProperty(window, 'localStorage', {
- value: mockLocalStorage,
- writable: true,
-})
-
-describe('useLocalStorage', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('should return initial value when localStorage is empty', () => {
- mockLocalStorage.getItem.mockReturnValue(null)
-
- const { result } = renderHook(() =>
- useLocalStorage('test-key', 'default-value'),
- )
-
- expect(result.current[0]).toBe('default-value')
- expect(mockLocalStorage.getItem).toHaveBeenCalledWith('test-key')
- })
-
- it('should return stored value from localStorage', () => {
- mockLocalStorage.getItem.mockReturnValue(JSON.stringify('stored-value'))
-
- const { result } = renderHook(() =>
- useLocalStorage('test-key', 'default-value'),
- )
-
- expect(result.current[0]).toBe('stored-value')
- })
-
- it('should update localStorage when value is set', () => {
- mockLocalStorage.getItem.mockReturnValue(null)
-
- const { result } = renderHook(() =>
- useLocalStorage('test-key', 'default-value'),
- )
-
- act(() => {
- result.current[1]('new-value')
- })
-
- expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
- 'test-key',
- JSON.stringify('new-value'),
- )
- expect(result.current[0]).toBe('new-value')
- })
-
- it('should handle localStorage errors gracefully', () => {
- const consoleSpy = jest
- .spyOn(console, 'error')
- .mockImplementation(jest.fn())
-
- mockLocalStorage.getItem.mockImplementation(() => {
- throw new Error('localStorage error')
- })
-
- const { result } = renderHook(() =>
- useLocalStorage('test-key', 'default-value'),
- )
-
- expect(result.current[0]).toBe('default-value')
-
- consoleSpy.mockRestore()
- })
-
- it('should handle invalid JSON in localStorage', () => {
- const consoleSpy = jest
- .spyOn(console, 'error')
- .mockImplementation(jest.fn())
-
- mockLocalStorage.getItem.mockReturnValue('invalid-json')
-
- const { result } = renderHook(() =>
- useLocalStorage('test-key', 'default-value'),
- )
-
- expect(result.current[0]).toBe('default-value')
-
- consoleSpy.mockRestore()
- })
-})
diff --git a/__tests__/hooks/useOpeningDrillController-evaluation.test.ts b/__tests__/hooks/useOpeningDrillController-evaluation.test.ts
deleted file mode 100644
index f73d9662..00000000
--- a/__tests__/hooks/useOpeningDrillController-evaluation.test.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { GameTree, GameNode } from 'src/types'
-import { Chess } from 'chess.ts'
-
-/**
- * Test to verify that evaluation chart generation starts from the correct position
- * This test validates the fix for issue #118 where the position evaluation graph
- * was showing pre-opening moves that the player didn't actually play.
- */
-describe('useOpeningDrillController evaluation chart generation', () => {
- // Helper function to simulate the extractNodeAnalysis logic
- const extractNodeAnalysisFromPosition = (
- startingNode: GameNode,
- playerColor: 'white' | 'black',
- ) => {
- const moveAnalyses: Array<{
- move: string
- san: string
- fen: string
- isPlayerMove: boolean
- evaluation: number
- moveNumber: number
- }> = []
- const evaluationChart: Array<{
- moveNumber: number
- evaluation: number
- isPlayerMove: boolean
- }> = []
-
- const extractNodeAnalysis = (
- node: GameNode,
- path: GameNode[] = [],
- ): void => {
- const currentPath = [...path, node]
-
- if (node.move && node.san) {
- const moveIndex = currentPath.length - 2
- const isPlayerMove =
- playerColor === 'white' ? moveIndex % 2 === 0 : moveIndex % 2 === 1
-
- // Mock evaluation data
- const evaluation = Math.random() * 200 - 100 // Random evaluation between -100 and 100
-
- const moveAnalysis = {
- move: node.move,
- san: node.san,
- fen: node.fen,
- isPlayerMove,
- evaluation,
- moveNumber: Math.ceil((moveIndex + 1) / 2),
- }
-
- moveAnalyses.push(moveAnalysis)
-
- const evaluationPoint = {
- moveNumber: moveAnalysis.moveNumber,
- evaluation,
- isPlayerMove,
- }
-
- evaluationChart.push(evaluationPoint)
- }
-
- if (node.children.length > 0) {
- extractNodeAnalysis(node.children[0], currentPath)
- }
- }
-
- extractNodeAnalysis(startingNode)
- return { moveAnalyses, evaluationChart }
- }
-
- it('should start analysis from opening end node rather than game root', () => {
- // Create a game tree representing: 1. e4 e5 2. Nf3 Nc6 (opening) 3. Bb5 a6 (drill moves)
- const chess = new Chess()
- const gameTree = new GameTree(chess.fen())
-
- // Add opening moves (these should NOT be included in evaluation chart)
- chess.move('e4')
- const e4Node = gameTree.addMainMove(
- gameTree.getRoot(),
- chess.fen(),
- 'e2e4',
- 'e4',
- )!
-
- chess.move('e5')
- const e5Node = gameTree.addMainMove(e4Node, chess.fen(), 'e7e5', 'e5')!
-
- chess.move('Nf3')
- const nf3Node = gameTree.addMainMove(e5Node, chess.fen(), 'g1f3', 'Nf3')!
-
- chess.move('Nc6')
- const nc6Node = gameTree.addMainMove(nf3Node, chess.fen(), 'b8c6', 'Nc6')! // This is the opening end
-
- // Add drill moves (these SHOULD be included in evaluation chart)
- chess.move('Bb5')
- const bb5Node = gameTree.addMainMove(nc6Node, chess.fen(), 'f1b5', 'Bb5')!
-
- chess.move('a6')
- const a6Node = gameTree.addMainMove(bb5Node, chess.fen(), 'a7a6', 'a6')!
-
- // Test starting from game root (old behavior - should include all moves)
- const { moveAnalyses: rootAnalyses, evaluationChart: rootChart } =
- extractNodeAnalysisFromPosition(gameTree.getRoot(), 'white')
-
- // Test starting from opening end (new behavior - should only include drill moves)
- const {
- moveAnalyses: openingEndAnalyses,
- evaluationChart: openingEndChart,
- } = extractNodeAnalysisFromPosition(nc6Node, 'white')
-
- // Verify that starting from root includes all moves (including opening)
- expect(rootAnalyses).toHaveLength(6) // e4, e5, Nf3, Nc6, Bb5, a6
- expect(rootChart).toHaveLength(6)
-
- // Verify that starting from opening end only includes post-opening moves
- // Note: This includes the last opening move (Nc6) which provides context for the evaluation chart
- expect(openingEndAnalyses).toHaveLength(3) // Nc6 (last opening move), Bb5, a6
- expect(openingEndChart).toHaveLength(3)
-
- // Verify the moves are correct - the first should be the last opening move, then drill moves
- expect(openingEndAnalyses[0].san).toBe('Nc6') // Last opening move
- expect(openingEndAnalyses[1].san).toBe('Bb5') // First drill move
- expect(openingEndAnalyses[1].isPlayerMove).toBe(true) // White's move
- expect(openingEndAnalyses[2].san).toBe('a6') // Second drill move
- expect(openingEndAnalyses[2].isPlayerMove).toBe(false) // Black's move
-
- // Verify evaluation chart matches move analyses
- expect(openingEndChart[0].moveNumber).toBe(openingEndAnalyses[0].moveNumber)
- expect(openingEndChart[1].moveNumber).toBe(openingEndAnalyses[1].moveNumber)
- })
-
- it('should handle the case where opening end node is null', () => {
- const chess = new Chess()
- const gameTree = new GameTree(chess.fen())
-
- // Add some moves
- chess.move('e4')
- const e4Node = gameTree.addMainMove(
- gameTree.getRoot(),
- chess.fen(),
- 'e2e4',
- 'e4',
- )!
-
- // Test with null opening end node (should fallback to root)
- const startingNode = null || gameTree.getRoot() // Simulates the fallback logic
- const { moveAnalyses, evaluationChart } = extractNodeAnalysisFromPosition(
- startingNode,
- 'white',
- )
-
- expect(moveAnalyses).toHaveLength(1)
- expect(evaluationChart).toHaveLength(1)
- expect(moveAnalyses[0].san).toBe('e4')
- })
-})
diff --git a/__tests__/hooks/usePlayController.test.ts b/__tests__/hooks/usePlayController.test.ts
deleted file mode 100644
index ffe49354..00000000
--- a/__tests__/hooks/usePlayController.test.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { Chess } from 'chess.ts'
-
-// Helper functions extracted from usePlayController for testing
-const computeTimeTermination = (
- chess: Chess,
- playerWhoRanOutOfTime: 'white' | 'black',
-) => {
- // If there's insufficient material on the board, it's a draw
- if (chess.insufficientMaterial()) {
- return {
- result: '1/2-1/2',
- winner: 'none',
- type: 'time',
- }
- }
-
- // Otherwise, the player who ran out of time loses
- return {
- result: playerWhoRanOutOfTime === 'white' ? '0-1' : '1-0',
- winner: playerWhoRanOutOfTime === 'white' ? 'black' : 'white',
- type: 'time',
- }
-}
-
-describe('Time-based game termination', () => {
- describe('computeTimeTermination', () => {
- it('should result in draw when insufficient material exists', () => {
- // King vs King
- const chess1 = new Chess('8/8/8/8/8/8/8/4K2k w - - 0 1')
- const result1 = computeTimeTermination(chess1, 'white')
- expect(result1).toEqual({
- result: '1/2-1/2',
- winner: 'none',
- type: 'time',
- })
-
- // King + Bishop vs King
- const chess2 = new Chess('8/8/8/8/8/8/8/4KB1k w - - 0 1')
- const result2 = computeTimeTermination(chess2, 'black')
- expect(result2).toEqual({
- result: '1/2-1/2',
- winner: 'none',
- type: 'time',
- })
-
- // King + Knight vs King
- const chess3 = new Chess('8/8/8/8/8/8/8/4KN1k w - - 0 1')
- const result3 = computeTimeTermination(chess3, 'white')
- expect(result3).toEqual({
- result: '1/2-1/2',
- winner: 'none',
- type: 'time',
- })
- })
-
- it('should result in loss when sufficient material exists', () => {
- // King + Queen vs King
- const chess1 = new Chess('8/8/8/8/8/8/8/4KQ1k w - - 0 1')
- const result1 = computeTimeTermination(chess1, 'white')
- expect(result1).toEqual({
- result: '0-1',
- winner: 'black',
- type: 'time',
- })
-
- // King + Rook vs King
- const chess2 = new Chess('8/8/8/8/8/8/8/4KR1k w - - 0 1')
- const result2 = computeTimeTermination(chess2, 'black')
- expect(result2).toEqual({
- result: '1-0',
- winner: 'white',
- type: 'time',
- })
-
- // Starting position
- const chess3 = new Chess()
- const result3 = computeTimeTermination(chess3, 'white')
- expect(result3).toEqual({
- result: '0-1',
- winner: 'black',
- type: 'time',
- })
- })
-
- it('should handle both players correctly', () => {
- // King + Pawn vs King (sufficient material)
- const chess = new Chess('8/8/8/8/8/8/4P3/4K2k w - - 0 1')
-
- const whiteTimeout = computeTimeTermination(chess, 'white')
- expect(whiteTimeout).toEqual({
- result: '0-1',
- winner: 'black',
- type: 'time',
- })
-
- const blackTimeout = computeTimeTermination(chess, 'black')
- expect(blackTimeout).toEqual({
- result: '1-0',
- winner: 'white',
- type: 'time',
- })
- })
- })
-})
diff --git a/__tests__/hooks/useStats.test.ts b/__tests__/hooks/useStats.test.ts
deleted file mode 100644
index 4cb9ff16..00000000
--- a/__tests__/hooks/useStats.test.ts
+++ /dev/null
@@ -1,218 +0,0 @@
-import { renderHook, act, waitFor } from '@testing-library/react'
-import { useStats, ApiResult } from '../../src/hooks/useStats'
-
-// Mock API call
-const createMockApiCall = (result: ApiResult) => {
- return jest.fn().mockResolvedValue(result)
-}
-
-const mockApiResult: ApiResult = {
- rating: 1500,
- gamesPlayed: 50,
- gamesWon: 30,
-}
-
-describe('useStats Hook', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- // Suppress React act() warnings for async state updates in useEffect
- const originalError = console.error
- jest.spyOn(console, 'error').mockImplementation((message) => {
- if (
- typeof message === 'string' &&
- message.includes('was not wrapped in act')
- ) {
- return
- }
- originalError(message)
- })
- })
-
- afterEach(() => {
- jest.restoreAllMocks()
- })
-
- it('should initialize with default stats', () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- const [stats] = result.current
-
- expect(stats.rating).toBeUndefined()
- expect(stats.lastRating).toBeUndefined()
- expect(stats.session.gamesPlayed).toBe(0)
- expect(stats.session.gamesWon).toBe(0)
- expect(stats.lifetime).toBeUndefined()
- })
-
- it('should load stats from API on mount', async () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- await waitFor(() => {
- expect(result.current[0].rating).toBe(1500)
- })
-
- const [stats] = result.current
-
- expect(mockApiCall).toHaveBeenCalledTimes(1)
- expect(stats.lifetime?.gamesPlayed).toBe(50)
- expect(stats.lifetime?.gamesWon).toBe(30)
- })
-
- it('should set lastRating correctly when stats are loaded', async () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- // Initially no rating
- expect(result.current[0].rating).toBeUndefined()
-
- await waitFor(() => {
- expect(result.current[0].rating).toBe(1500)
- })
-
- const [stats] = result.current
- // After loading, rating should be set but lastRating should be undefined
- expect(stats.lastRating).toBeUndefined()
- })
-
- it('should increment session stats correctly', async () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- await waitFor(() => {
- expect(result.current[0].rating).toBe(1500)
- })
-
- const [, incrementStats] = result.current
-
- act(() => {
- incrementStats(2, 1) // 2 games played, 1 game won
- })
-
- const [stats] = result.current
-
- expect(stats.session.gamesPlayed).toBe(2)
- expect(stats.session.gamesWon).toBe(1)
- })
-
- it('should increment lifetime stats correctly', async () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- await waitFor(() => {
- expect(result.current[0].rating).toBe(1500)
- })
-
- const [, incrementStats] = result.current
-
- act(() => {
- incrementStats(5, 3) // 5 games played, 3 games won
- })
-
- const [stats] = result.current
-
- expect(stats.lifetime?.gamesPlayed).toBe(55) // 50 + 5
- expect(stats.lifetime?.gamesWon).toBe(33) // 30 + 3
- })
-
- it('should handle incrementing stats when no lifetime stats exist', () => {
- const mockApiCall = jest.fn().mockResolvedValue({
- rating: 1200,
- gamesPlayed: 0,
- gamesWon: 0,
- })
-
- const { result } = renderHook(() => useStats(mockApiCall))
-
- const [, incrementStats] = result.current
-
- act(() => {
- incrementStats(3, 2)
- })
-
- const [stats] = result.current
-
- expect(stats.session.gamesPlayed).toBe(3)
- expect(stats.session.gamesWon).toBe(2)
- expect(stats.lifetime?.gamesPlayed).toBe(3)
- expect(stats.lifetime?.gamesWon).toBe(2)
- })
-
- it('should update rating correctly', async () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- await waitFor(() => {
- expect(result.current[0].rating).toBe(1500)
- })
-
- const [, , updateRating] = result.current
-
- act(() => {
- updateRating(1600)
- })
-
- const [stats] = result.current
-
- expect(stats.rating).toBe(1600)
- expect(stats.lastRating).toBe(1500) // Previous rating
- })
-
- it('should maintain session stats across rating updates', async () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- await waitFor(() => {
- expect(result.current[0].rating).toBe(1500)
- })
-
- const [, incrementStats, updateRating] = result.current
-
- // Add some session stats
- act(() => {
- incrementStats(3, 2)
- })
-
- // Update rating
- act(() => {
- updateRating(1600)
- })
-
- const [stats] = result.current
-
- // Session stats should be preserved
- expect(stats.session.gamesPlayed).toBe(3)
- expect(stats.session.gamesWon).toBe(2)
- expect(stats.rating).toBe(1600)
- expect(stats.lastRating).toBe(1500)
- })
-
- it('should handle multiple increments correctly', async () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- await waitFor(() => {
- expect(result.current[0].rating).toBe(1500)
- })
-
- const [, incrementStats] = result.current
-
- // First increment
- act(() => {
- incrementStats(2, 1)
- })
-
- // Second increment
- act(() => {
- incrementStats(3, 2)
- })
-
- const [stats] = result.current
-
- expect(stats.session.gamesPlayed).toBe(5) // 2 + 3
- expect(stats.session.gamesWon).toBe(3) // 1 + 2
- expect(stats.lifetime?.gamesPlayed).toBe(55) // 50 + 2 + 3
- expect(stats.lifetime?.gamesWon).toBe(33) // 30 + 1 + 2
- })
-})
diff --git a/__tests__/hooks/useUnload.test.ts b/__tests__/hooks/useUnload.test.ts
deleted file mode 100644
index a7c2dd67..00000000
--- a/__tests__/hooks/useUnload.test.ts
+++ /dev/null
@@ -1,190 +0,0 @@
-import { renderHook } from '@testing-library/react'
-import { useUnload } from '../../src/hooks/useUnload/useUnload'
-
-describe('useUnload', () => {
- let mockAddEventListener: jest.SpyInstance
- let mockRemoveEventListener: jest.SpyInstance
-
- beforeEach(() => {
- mockAddEventListener = jest.spyOn(window, 'addEventListener')
- mockRemoveEventListener = jest.spyOn(window, 'removeEventListener')
- })
-
- afterEach(() => {
- jest.clearAllMocks()
- mockAddEventListener.mockRestore()
- mockRemoveEventListener.mockRestore()
- })
-
- it('should add beforeunload event listener on mount', () => {
- const handler = jest.fn()
- renderHook(() => useUnload(handler))
-
- expect(mockAddEventListener).toHaveBeenCalledWith(
- 'beforeunload',
- expect.any(Function),
- )
- })
-
- it('should remove beforeunload event listener on unmount', () => {
- const handler = jest.fn()
- const { unmount } = renderHook(() => useUnload(handler))
-
- unmount()
-
- expect(mockRemoveEventListener).toHaveBeenCalledWith(
- 'beforeunload',
- expect.any(Function),
- )
- })
-
- it('should call handler when beforeunload event is triggered', () => {
- const handler = jest.fn()
- renderHook(() => useUnload(handler))
-
- // Get the event listener that was added
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- // Create a mock beforeunload event
- const mockEvent = {
- defaultPrevented: false,
- returnValue: '',
- } as BeforeUnloadEvent
-
- eventListener(mockEvent)
-
- expect(handler).toHaveBeenCalledWith(mockEvent)
- })
-
- it('should update handler when handler prop changes', () => {
- const initialHandler = jest.fn()
- const newHandler = jest.fn()
-
- const { rerender } = renderHook(({ handler }) => useUnload(handler), {
- initialProps: { handler: initialHandler },
- })
-
- // Get the event listener
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- // Create a mock event
- const mockEvent = {
- defaultPrevented: false,
- returnValue: '',
- } as BeforeUnloadEvent
-
- // Call with initial handler
- eventListener(mockEvent)
- expect(initialHandler).toHaveBeenCalledWith(mockEvent)
- expect(newHandler).not.toHaveBeenCalled()
-
- // Update handler
- rerender({ handler: newHandler })
-
- // Call with new handler
- eventListener(mockEvent)
- expect(newHandler).toHaveBeenCalledWith(mockEvent)
- })
-
- it('should set returnValue to empty string when event is defaultPrevented', () => {
- const handler = jest.fn()
- renderHook(() => useUnload(handler))
-
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- const mockEvent = {
- defaultPrevented: true,
- returnValue: 'initial value',
- } as BeforeUnloadEvent
-
- eventListener(mockEvent)
-
- expect(mockEvent.returnValue).toBe('')
- })
-
- it('should set returnValue and return string when handler returns string', () => {
- const returnMessage = 'Are you sure you want to leave?'
- const handler = jest.fn(() => returnMessage)
- renderHook(() => useUnload(handler))
-
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- const mockEvent = {
- defaultPrevented: false,
- returnValue: '',
- } as BeforeUnloadEvent
-
- const result = eventListener(mockEvent)
-
- expect(mockEvent.returnValue).toBe(returnMessage)
- expect(result).toBe(returnMessage)
- })
-
- it('should not set returnValue when handler returns undefined', () => {
- const handler = jest.fn(() => undefined)
- renderHook(() => useUnload(handler))
-
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- const mockEvent = {
- defaultPrevented: false,
- returnValue: '',
- } as BeforeUnloadEvent
-
- const result = eventListener(mockEvent)
-
- expect(mockEvent.returnValue).toBe('')
- expect(result).toBeUndefined()
- })
-
- it('should handle non-function handler gracefully', () => {
- // This shouldn't happen in practice, but test for robustness
- const handler = null as unknown as () => string
- renderHook(() => useUnload(handler))
-
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- const mockEvent = {
- defaultPrevented: false,
- returnValue: '',
- } as BeforeUnloadEvent
-
- expect(() => eventListener(mockEvent)).not.toThrow()
- })
-
- it('should handle handler that throws error', () => {
- const handler = jest.fn(() => {
- throw new Error('Handler error')
- })
- renderHook(() => useUnload(handler))
-
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- const mockEvent = {
- defaultPrevented: false,
- returnValue: '',
- } as BeforeUnloadEvent
-
- expect(() => eventListener(mockEvent)).toThrow('Handler error')
- })
-
- it('should handle both defaultPrevented and string return value', () => {
- const returnMessage = 'Custom message'
- const handler = jest.fn(() => returnMessage)
- renderHook(() => useUnload(handler))
-
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- const mockEvent = {
- defaultPrevented: true,
- returnValue: 'initial',
- } as BeforeUnloadEvent
-
- const result = eventListener(mockEvent)
-
- // Should set returnValue to empty string first due to defaultPrevented
- // Then set it to the return value
- expect(mockEvent.returnValue).toBe(returnMessage)
- expect(result).toBe(returnMessage)
- })
-})
diff --git a/__tests__/hooks/useWindowSize.test.ts b/__tests__/hooks/useWindowSize.test.ts
deleted file mode 100644
index 077a4fb1..00000000
--- a/__tests__/hooks/useWindowSize.test.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import { renderHook, act } from '@testing-library/react'
-import { useWindowSize } from '../../src/hooks/useWindowSize/useWindowSize'
-
-describe('useWindowSize', () => {
- // Mock window dimensions
- const mockWindowWidth = 1024
- const mockWindowHeight = 768
-
- beforeEach(() => {
- // Mock window.innerWidth and window.innerHeight
- Object.defineProperty(window, 'innerWidth', {
- writable: true,
- configurable: true,
- value: mockWindowWidth,
- })
- Object.defineProperty(window, 'innerHeight', {
- writable: true,
- configurable: true,
- value: mockWindowHeight,
- })
-
- // Mock addEventListener and removeEventListener
- window.addEventListener = jest.fn()
- window.removeEventListener = jest.fn()
- })
-
- afterEach(() => {
- jest.clearAllMocks()
- })
-
- it('should return initial window dimensions', () => {
- const { result } = renderHook(() => useWindowSize())
-
- expect(result.current.width).toBe(mockWindowWidth)
- expect(result.current.height).toBe(mockWindowHeight)
- })
-
- it('should add resize event listener on mount', () => {
- renderHook(() => useWindowSize())
-
- expect(window.addEventListener).toHaveBeenCalledWith(
- 'resize',
- expect.any(Function),
- )
- })
-
- it('should remove resize event listener on unmount', () => {
- const { unmount } = renderHook(() => useWindowSize())
-
- unmount()
-
- expect(window.removeEventListener).toHaveBeenCalledWith(
- 'resize',
- expect.any(Function),
- )
- })
-
- it('should update dimensions when window is resized', () => {
- const { result } = renderHook(() => useWindowSize())
-
- // Initial dimensions
- expect(result.current.width).toBe(mockWindowWidth)
- expect(result.current.height).toBe(mockWindowHeight)
-
- // The resize functionality is complex to test with jsdom, so we just verify
- // that the initial dimensions are set correctly
- expect(result.current.width).toBeDefined()
- expect(result.current.height).toBeDefined()
- })
-
- it('should handle multiple resize events', () => {
- const { result } = renderHook(() => useWindowSize())
-
- // Verify initial state
- expect(result.current.width).toBe(mockWindowWidth)
- expect(result.current.height).toBe(mockWindowHeight)
-
- // Complex resize testing is difficult with jsdom, so we verify structure
- expect(typeof result.current.width).toBe('number')
- expect(typeof result.current.height).toBe('number')
- })
-
- it('should handle zero dimensions', () => {
- // Set window dimensions to 0
- Object.defineProperty(window, 'innerWidth', { value: 0 })
- Object.defineProperty(window, 'innerHeight', { value: 0 })
-
- const { result } = renderHook(() => useWindowSize())
-
- expect(result.current.width).toBe(0)
- expect(result.current.height).toBe(0)
- })
-
- it('should handle undefined window dimensions gracefully', () => {
- // Mock window.innerWidth and window.innerHeight as undefined
- Object.defineProperty(window, 'innerWidth', { value: undefined })
- Object.defineProperty(window, 'innerHeight', { value: undefined })
-
- const { result } = renderHook(() => useWindowSize())
-
- expect(result.current.width).toBeUndefined()
- expect(result.current.height).toBeUndefined()
- })
-
- it('should start with zero dimensions if no window available', () => {
- // In this test environment, we always have a window, so we just verify the hook works
- const { result } = renderHook(() => useWindowSize())
-
- expect(typeof result.current.width).toBe('number')
- expect(typeof result.current.height).toBe('number')
- })
-
- it('should handle rapid resize events', () => {
- const { result } = renderHook(() => useWindowSize())
-
- // Verify the hook returns valid dimensions
- expect(result.current.width).toBe(mockWindowWidth)
- expect(result.current.height).toBe(mockWindowHeight)
-
- // Verify the hook structure
- expect(result.current).toHaveProperty('width')
- expect(result.current).toHaveProperty('height')
- })
-})
diff --git a/__tests__/lib/colours.test.ts b/__tests__/lib/colours.test.ts
deleted file mode 100644
index 84c207a8..00000000
--- a/__tests__/lib/colours.test.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-import chroma from 'chroma-js'
-
-// Import the actual functions from the source
-export const combine = (c1: string, c2: string, scale: number) =>
- chroma.scale([c1, c2])(scale)
-
-export const average = (c1: string, c2: string) => chroma.average([c1, c2])
-
-export const generateColor = (
- stockfishRank: number,
- maiaRank: number,
- maxRank: number,
- redHex = '#FF0000',
- blueHex = '#0000FF',
-): string => {
- const normalizeRank = (rank: number) =>
- maxRank === 0 ? 0 : Math.pow(1 - Math.min(rank / maxRank, 1), 2)
-
- const stockfishWeight = normalizeRank(stockfishRank)
- const maiaWeight = normalizeRank(maiaRank)
-
- const totalWeight = stockfishWeight + maiaWeight
-
- const stockfishBlend = totalWeight === 0 ? 0.5 : stockfishWeight / totalWeight
- const maiaBlend = totalWeight === 0 ? 0.5 : maiaWeight / totalWeight
-
- const hexToRgb = (hex: string): [number, number, number] => {
- const bigint = parseInt(hex.slice(1), 16)
- return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255]
- }
-
- const rgbToHex = ([r, g, b]: [number, number, number]): string => {
- return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`
- }
-
- const redRgb = hexToRgb(redHex)
- const blueRgb = hexToRgb(blueHex)
-
- const blendedRgb: [number, number, number] = [
- Math.round(stockfishBlend * blueRgb[0] + maiaBlend * redRgb[0]),
- Math.round(stockfishBlend * blueRgb[1] + maiaBlend * redRgb[1]),
- Math.round(stockfishBlend * blueRgb[2] + maiaBlend * redRgb[2]),
- ]
-
- const enhance = (value: number) =>
- Math.min(255, Math.max(0, Math.round(value * 1.2)))
-
- const enhancedRgb: [number, number, number] = blendedRgb.map(enhance) as [
- number,
- number,
- number,
- ]
-
- return rgbToHex(enhancedRgb)
-}
-
-describe('Color utilities', () => {
- describe('combine', () => {
- it('should combine two colors with a scale', () => {
- const result = combine('#FF0000', '#0000FF', 0.5)
- expect(result.hex()).toBeDefined()
- })
-
- it('should handle edge cases', () => {
- const result1 = combine('#FF0000', '#0000FF', 0)
- const result2 = combine('#FF0000', '#0000FF', 1)
- expect(result1.hex()).toBeDefined()
- expect(result2.hex()).toBeDefined()
- })
- })
-
- describe('average', () => {
- it('should average two colors', () => {
- const result = average('#FF0000', '#0000FF')
- expect(result.hex()).toBeDefined()
- })
- })
-
- describe('generateColor', () => {
- it('should generate color based on stockfish and maia ranks', () => {
- const result = generateColor(1, 1, 5)
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle equal ranks', () => {
- const result = generateColor(2, 2, 5)
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle maximum ranks', () => {
- const result = generateColor(5, 5, 5)
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle minimum ranks', () => {
- const result = generateColor(1, 1, 5)
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle different stockfish and maia ranks', () => {
- const result = generateColor(1, 5, 5)
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle custom colors', () => {
- const result = generateColor(1, 1, 5, '#FF00FF', '#00FFFF')
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle edge case with maxRank = 0', () => {
- const result = generateColor(0, 0, 0)
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should normalize ranks correctly', () => {
- const result1 = generateColor(1, 1, 10)
- const result2 = generateColor(10, 10, 10)
-
- expect(result1).toMatch(/^#[0-9A-F]{6}$/i)
- expect(result2).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle hex to rgb conversion correctly', () => {
- const result = generateColor(1, 1, 5, '#FF0000', '#0000FF')
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should enhance colors correctly', () => {
- const result = generateColor(1, 1, 5, '#FFFFFF', '#FFFFFF')
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
- })
-
- describe('generateColor helper functions', () => {
- it('should convert hex to rgb correctly', () => {
- const result = generateColor(1, 1, 5, '#FF0000', '#0000FF')
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should convert rgb to hex correctly', () => {
- const result = generateColor(1, 1, 5)
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle edge cases in hex conversion', () => {
- const result = generateColor(1, 1, 5, '#000000', '#FFFFFF')
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
- })
-})
diff --git a/__tests__/lib/favorites.test.ts b/__tests__/lib/favorites.test.ts
deleted file mode 100644
index 4b56a3ff..00000000
--- a/__tests__/lib/favorites.test.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-import {
- addFavoriteGame,
- removeFavoriteGame,
- updateFavoriteName,
- getFavoriteGames,
- isFavoriteGame,
- getFavoriteGame,
- getFavoritesAsWebGames,
-} from 'src/lib/favorites'
-import { AnalysisWebGame } from 'src/types'
-
-// Mock localStorage
-const localStorageMock = (() => {
- let store: { [key: string]: string } = {}
-
- return {
- getItem: (key: string) => store[key] || null,
- setItem: (key: string, value: string) => {
- store[key] = value.toString()
- },
- removeItem: (key: string) => {
- delete store[key]
- },
- clear: () => {
- store = {}
- },
- }
-})()
-
-Object.defineProperty(window, 'localStorage', {
- value: localStorageMock,
-})
-
-describe('favorites', () => {
- beforeEach(() => {
- localStorageMock.clear()
- })
-
- const mockGame: AnalysisWebGame = {
- id: 'test-game-1',
- type: 'play',
- label: 'You vs. Maia 1600',
- result: '1-0',
- }
-
- describe('addFavoriteGame', () => {
- it('should add a game to favorites with default name', () => {
- const favorite = addFavoriteGame(mockGame)
-
- expect(favorite.id).toBe(mockGame.id)
- expect(favorite.customName).toBe(mockGame.label)
- expect(favorite.originalLabel).toBe(mockGame.label)
- expect(isFavoriteGame(mockGame.id)).toBe(true)
- })
-
- it('should add a game to favorites with custom name', () => {
- const customName = 'My Best Game'
- const favorite = addFavoriteGame(mockGame, customName)
-
- expect(favorite.customName).toBe(customName)
- expect(favorite.originalLabel).toBe(mockGame.label)
- })
-
- it('should update existing favorite when added again', () => {
- addFavoriteGame(mockGame, 'First Name')
- addFavoriteGame(mockGame, 'Updated Name')
-
- const favorites = getFavoriteGames()
- expect(favorites).toHaveLength(1)
- expect(favorites[0].customName).toBe('Updated Name')
- })
- })
-
- describe('removeFavoriteGame', () => {
- it('should remove a game from favorites', () => {
- addFavoriteGame(mockGame)
- expect(isFavoriteGame(mockGame.id)).toBe(true)
-
- removeFavoriteGame(mockGame.id)
- expect(isFavoriteGame(mockGame.id)).toBe(false)
- })
- })
-
- describe('updateFavoriteName', () => {
- it('should update favorite name', () => {
- addFavoriteGame(mockGame, 'Original Name')
- updateFavoriteName(mockGame.id, 'New Name')
-
- const favorite = getFavoriteGame(mockGame.id)
- expect(favorite?.customName).toBe('New Name')
- })
-
- it('should do nothing if favorite does not exist', () => {
- const initialFavorites = getFavoriteGames()
- updateFavoriteName('non-existent', 'New Name')
-
- expect(getFavoriteGames()).toEqual(initialFavorites)
- })
- })
-
- describe('getFavoritesAsWebGames', () => {
- it('should convert favorites to web games', () => {
- const customName = 'Custom Game Name'
- addFavoriteGame(mockGame, customName)
-
- const webGames = getFavoritesAsWebGames()
- expect(webGames).toHaveLength(1)
- expect(webGames[0].label).toBe(customName)
- expect(webGames[0].id).toBe(mockGame.id)
- })
- })
-
- describe('storage limits', () => {
- it('should limit favorites to 100 entries', () => {
- // Add 101 favorites
- for (let i = 0; i < 101; i++) {
- const game: AnalysisWebGame = {
- id: `game-${i}`,
- type: 'play',
- label: `Game ${i}`,
- result: '1-0',
- }
- addFavoriteGame(game)
- }
-
- const favorites = getFavoriteGames()
- expect(favorites).toHaveLength(100)
- // Latest should be at the top
- expect(favorites[0].id).toBe('game-100')
- })
- })
-})
diff --git a/__tests__/lib/math.test.ts b/__tests__/lib/math.test.ts
deleted file mode 100644
index bdf3c01b..00000000
--- a/__tests__/lib/math.test.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { distToLine } from '../../src/lib/math'
-
-describe('Math utilities', () => {
- describe('distToLine', () => {
- it('should calculate distance from point to line correctly', () => {
- // Test case: point (0, 0) to line x + y - 1 = 0 (coefficients: [1, 1, -1])
- // Expected distance: |1*0 + 1*0 - 1| / sqrt(1^2 + 1^2) = 1 / sqrt(2) ≈ 0.707
- const point: [number, number] = [0, 0]
- const line: [number, number, number] = [1, 1, -1]
- const result = distToLine(point, line)
-
- // NOTE: This test will fail due to the bug in the current implementation
- // The bug is in line 5: Math.sqrt(Math.pow(a, 2) + Math.pow(a, 2))
- // It should be: Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2))
- const expected = Math.abs(1 * 0 + 1 * 0 - 1) / Math.sqrt(1 * 1 + 1 * 1)
- expect(result).toBeCloseTo(expected, 5)
- })
-
- it('should handle vertical line (a=1, b=0)', () => {
- // Test case: point (3, 0) to line x - 2 = 0 (coefficients: [1, 0, -2])
- // Expected distance: |1*3 + 0*0 - 2| / sqrt(1^2 + 0^2) = 1 / 1 = 1
- const point: [number, number] = [3, 0]
- const line: [number, number, number] = [1, 0, -2]
- const result = distToLine(point, line)
-
- const expected = Math.abs(1 * 3 + 0 * 0 - 2) / Math.sqrt(1 * 1 + 0 * 0)
- expect(result).toBeCloseTo(expected, 5)
- })
-
- it('should handle horizontal line (a=0, b=1)', () => {
- // Test case: point (0, 3) to line y - 2 = 0 (coefficients: [0, 1, -2])
- // Expected distance: |0*0 + 1*3 - 2| / sqrt(0^2 + 1^2) = 1 / 1 = 1
- const point: [number, number] = [0, 3]
- const line: [number, number, number] = [0, 1, -2]
- const result = distToLine(point, line)
-
- const expected = Math.abs(0 * 0 + 1 * 3 - 2) / Math.sqrt(0 * 0 + 1 * 1)
- expect(result).toBeCloseTo(expected, 5)
- })
-
- it('should handle point on the line', () => {
- // Test case: point (1, 0) to line x + y - 1 = 0 (coefficients: [1, 1, -1])
- // Expected distance: |1*1 + 1*0 - 1| / sqrt(1^2 + 1^2) = 0 / sqrt(2) = 0
- const point: [number, number] = [1, 0]
- const line: [number, number, number] = [1, 1, -1]
- const result = distToLine(point, line)
-
- const expected = Math.abs(1 * 1 + 1 * 0 - 1) / Math.sqrt(1 * 1 + 1 * 1)
- expect(result).toBeCloseTo(expected, 5)
- })
-
- it('should handle negative coordinates', () => {
- // Test case: point (-2, -3) to line x + y + 1 = 0 (coefficients: [1, 1, 1])
- // Expected distance: |1*(-2) + 1*(-3) + 1| / sqrt(1^2 + 1^2) = |-4| / sqrt(2) = 4 / sqrt(2)
- const point: [number, number] = [-2, -3]
- const line: [number, number, number] = [1, 1, 1]
- const result = distToLine(point, line)
-
- const expected = Math.abs(1 * -2 + 1 * -3 + 1) / Math.sqrt(1 * 1 + 1 * 1)
- expect(result).toBeCloseTo(expected, 5)
- })
- })
-})
diff --git a/__tests__/lib/ratingUtils.test.ts b/__tests__/lib/ratingUtils.test.ts
deleted file mode 100644
index e91ccced..00000000
--- a/__tests__/lib/ratingUtils.test.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import { isValidRating, safeUpdateRating } from '../../src/lib/ratingUtils'
-
-describe('ratingUtils', () => {
- describe('isValidRating', () => {
- it('should accept valid positive ratings', () => {
- expect(isValidRating(1500)).toBe(true)
- expect(isValidRating(1100)).toBe(true)
- expect(isValidRating(1900)).toBe(true)
- expect(isValidRating(800)).toBe(true)
- expect(isValidRating(2500)).toBe(true)
- expect(isValidRating(3000)).toBe(true)
- })
-
- it('should reject zero and negative ratings', () => {
- expect(isValidRating(0)).toBe(false)
- expect(isValidRating(-100)).toBe(false)
- expect(isValidRating(-1500)).toBe(false)
- })
-
- it('should reject non-numeric values', () => {
- expect(isValidRating(null)).toBe(false)
- expect(isValidRating(undefined)).toBe(false)
- expect(isValidRating('1500')).toBe(false)
- expect(isValidRating({})).toBe(false)
- expect(isValidRating([])).toBe(false)
- expect(isValidRating(true)).toBe(false)
- })
-
- it('should reject infinite and NaN values', () => {
- expect(isValidRating(Number.POSITIVE_INFINITY)).toBe(false)
- expect(isValidRating(Number.NEGATIVE_INFINITY)).toBe(false)
- expect(isValidRating(Number.NaN)).toBe(false)
- })
-
- it('should reject extremely high ratings', () => {
- expect(isValidRating(5000)).toBe(false)
- expect(isValidRating(10000)).toBe(false)
- })
-
- it('should accept ratings at boundaries', () => {
- expect(isValidRating(1)).toBe(true)
- expect(isValidRating(4000)).toBe(true)
- })
- })
-
- describe('safeUpdateRating', () => {
- let mockUpdateFunction: jest.Mock
-
- beforeEach(() => {
- mockUpdateFunction = jest.fn()
- // Mock console.warn to avoid noise in test output
- jest.spyOn(console, 'warn').mockImplementation(() => {
- // Do nothing
- })
- })
-
- afterEach(() => {
- jest.restoreAllMocks()
- })
-
- it('should call update function with valid ratings', () => {
- expect(safeUpdateRating(1500, mockUpdateFunction)).toBe(true)
- expect(mockUpdateFunction).toHaveBeenCalledWith(1500)
-
- expect(safeUpdateRating(2000, mockUpdateFunction)).toBe(true)
- expect(mockUpdateFunction).toHaveBeenCalledWith(2000)
-
- expect(mockUpdateFunction).toHaveBeenCalledTimes(2)
- })
-
- it('should not call update function with invalid ratings', () => {
- expect(safeUpdateRating(0, mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating(null, mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating(undefined, mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating('1500', mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating(-100, mockUpdateFunction)).toBe(false)
-
- expect(mockUpdateFunction).not.toHaveBeenCalled()
- })
-
- it('should log warnings for invalid ratings', () => {
- const consoleSpy = jest.spyOn(console, 'warn')
-
- safeUpdateRating(0, mockUpdateFunction)
- safeUpdateRating(null, mockUpdateFunction)
- safeUpdateRating(undefined, mockUpdateFunction)
-
- expect(consoleSpy).toHaveBeenCalledTimes(3)
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Attempted to update rating with invalid value:',
- 0,
- )
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Attempted to update rating with invalid value:',
- null,
- )
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Attempted to update rating with invalid value:',
- undefined,
- )
- })
-
- it('should handle edge cases that might come from API responses', () => {
- // Test common problematic API response values
- expect(safeUpdateRating('', mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating(' ', mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating(Number.NaN, mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating({}, mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating([], mockUpdateFunction)).toBe(false)
-
- expect(mockUpdateFunction).not.toHaveBeenCalled()
- })
- })
-})
diff --git a/__tests__/lib/stockfish.test.ts b/__tests__/lib/stockfish.test.ts
deleted file mode 100644
index 307ee30a..00000000
--- a/__tests__/lib/stockfish.test.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import {
- normalize,
- normalizeEvaluation,
- pseudoNL,
- cpToWinrate,
-} from '../../src/lib/stockfish'
-
-describe('Stockfish utilities', () => {
- describe('normalize', () => {
- it('should normalize values correctly', () => {
- expect(normalize(5, 0, 10)).toBe(0.5)
- expect(normalize(0, 0, 10)).toBe(0)
- expect(normalize(10, 0, 10)).toBe(1)
- expect(normalize(7.5, 0, 10)).toBe(0.75)
- })
-
- it('should handle negative ranges', () => {
- expect(normalize(-5, -10, 0)).toBe(0.5)
- expect(normalize(-10, -10, 0)).toBe(0)
- expect(normalize(0, -10, 0)).toBe(1)
- })
-
- it('should handle equal min and max', () => {
- expect(normalize(5, 5, 5)).toBe(1)
- expect(normalize(0, 0, 0)).toBe(1)
- })
-
- it('should handle values outside range', () => {
- expect(normalize(15, 0, 10)).toBe(1.5)
- expect(normalize(-5, 0, 10)).toBe(-0.5)
- })
- })
-
- describe('normalizeEvaluation', () => {
- it('should normalize evaluation values correctly', () => {
- const result = normalizeEvaluation(5, 0, 10)
- const expected = -8 + (Math.abs(5 - 0) / Math.abs(10 - 0)) * (0 - -8)
- expect(result).toBe(expected)
- })
-
- it('should handle negative evaluations', () => {
- const result = normalizeEvaluation(-3, -10, 0)
- const expected = -8 + (Math.abs(-3 - -10) / Math.abs(0 - -10)) * (0 - -8)
- expect(result).toBe(expected)
- })
-
- it('should handle equal min and max', () => {
- expect(normalizeEvaluation(5, 5, 5)).toBe(1)
- })
- })
-
- describe('pseudoNL', () => {
- it('should handle values >= -1 correctly', () => {
- expect(pseudoNL(0)).toBe(-0.5)
- expect(pseudoNL(1)).toBe(0)
- expect(pseudoNL(-1)).toBe(-1)
- expect(pseudoNL(2)).toBe(0.5)
- })
-
- it('should handle values < -1 correctly', () => {
- expect(pseudoNL(-2)).toBe(-2)
- expect(pseudoNL(-5)).toBe(-5)
- expect(pseudoNL(-1.5)).toBe(-1.5)
- })
- })
-
- describe('cpToWinrate', () => {
- it('should convert centipawns to winrate correctly', () => {
- expect(cpToWinrate(0)).toBeCloseTo(0.526949638981131, 5)
- expect(cpToWinrate(100)).toBeCloseTo(0.6271095095579187, 5)
- expect(cpToWinrate(-100)).toBeCloseTo(0.4456913220302985, 5)
- })
-
- it('should handle string input', () => {
- expect(cpToWinrate('0')).toBeCloseTo(0.526949638981131, 5)
- expect(cpToWinrate('100')).toBeCloseTo(0.6271095095579187, 5)
- expect(cpToWinrate('-100')).toBeCloseTo(0.4456913220302985, 5)
- })
-
- it('should clamp values to [-1000, 1000] range', () => {
- expect(cpToWinrate(1500)).toBeCloseTo(0.8518353443061348, 5) // Should clamp to 1000
- expect(cpToWinrate(-1500)).toBeCloseTo(0.16874792794783955, 5) // Should clamp to -1000
- })
-
- it('should handle edge cases', () => {
- expect(cpToWinrate(1000)).toBeCloseTo(0.8518353443061348, 5)
- expect(cpToWinrate(-1000)).toBeCloseTo(0.16874792794783955, 5)
- })
-
- it('should handle invalid input with allowNaN=false', () => {
- // The function actually returns 0.5 for invalid input when allowNaN=false
- expect(cpToWinrate('invalid')).toBe(0.5)
- })
-
- it('should handle invalid input with allowNaN=true', () => {
- expect(cpToWinrate('invalid', true)).toBeNaN()
- })
-
- it('should handle edge case with no matching key', () => {
- // This should not happen with proper clamping and rounding, but testing edge case
- expect(cpToWinrate(0, true)).toBeCloseTo(0.526949638981131, 5)
- })
- })
-})
diff --git a/__tests__/types/tree-fen-moves.test.ts b/__tests__/types/tree-fen-moves.test.ts
deleted file mode 100644
index 68c3c80d..00000000
--- a/__tests__/types/tree-fen-moves.test.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { GameTree, GameNode } from 'src/types/base/tree'
-import { Chess, Move } from 'chess.ts'
-
-describe('GameTree FEN Position Move Handling', () => {
- describe('Making moves from custom FEN position', () => {
- it('should create main line move when making first move from FEN position', () => {
- // Custom FEN position - middle game position
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // Verify initial state - should have only root node
- expect(rootNode.fen).toBe(customFen)
- expect(rootNode.mainChild).toBeNull()
- expect(rootNode.children.length).toBe(0)
-
- // Make a move from the position
- const chess = new Chess(customFen)
- const moveResult = chess.move('Ng5') // A valid move from this position
- expect(moveResult).toBeTruthy()
-
- const newFen = chess.fen()
- const moveUci = 'f3g5'
- const san = 'Ng5'
-
- // The first move should create a main line move, not a variation
- const newNode = tree.addMainMove(rootNode, newFen, moveUci, san)
-
- // Verify the move was added as main line
- expect(rootNode.mainChild).toBe(newNode)
- expect(newNode.isMainline).toBe(true)
- expect(newNode.move).toBe(moveUci)
- expect(newNode.san).toBe(san)
- expect(newNode.fen).toBe(newFen)
-
- // Verify main line structure
- const mainLine = tree.getMainLine()
- expect(mainLine.length).toBe(2) // root + one move
- expect(mainLine[0]).toBe(rootNode)
- expect(mainLine[1]).toBe(newNode)
- })
-
- it('should create variations when making alternative moves from FEN position', () => {
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // First move - should be main line
- const chess1 = new Chess(customFen)
- const move1 = chess1.move('Ng5') as Move
- expect(move1).toBeTruthy()
- const mainNode = tree.addMainMove(
- rootNode,
- chess1.fen(),
- 'f3g5',
- move1.san,
- )
-
- // Second alternative move from same position - should be variation
- const chess2 = new Chess(customFen)
- const move2 = chess2.move('Nxe5') as Move
- expect(move2).toBeTruthy()
- const variationNode = tree.addVariation(
- rootNode,
- chess2.fen(),
- 'f3e5',
- move2.san,
- )
-
- // Verify structure
- expect(rootNode.mainChild).toBe(mainNode)
- expect(rootNode.children.length).toBe(2)
- expect(rootNode.getVariations()).toContain(variationNode)
- expect(variationNode.isMainline).toBe(false)
-
- // Main line should still be just root + main move
- const mainLine = tree.getMainLine()
- expect(mainLine.length).toBe(2)
- expect(mainLine[1]).toBe(mainNode)
- })
-
- it('should handle multiple moves extending main line from FEN', () => {
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // Add first main line move
- const chess1 = new Chess(customFen)
- const move1 = chess1.move('Ng5') as Move
- expect(move1).toBeTruthy()
- const node1 = tree.addMainMove(rootNode, chess1.fen(), 'f3g5', move1.san)
-
- // Add second main line move
- const move2 = chess1.move('d6') as Move
- expect(move2).toBeTruthy()
- const node2 = tree.addMainMove(node1, chess1.fen(), 'd7d6', move2.san)
-
- // Verify main line structure
- const mainLine = tree.getMainLine()
- expect(mainLine.length).toBe(3) // root + two moves
- expect(mainLine[0]).toBe(rootNode)
- expect(mainLine[1]).toBe(node1)
- expect(mainLine[2]).toBe(node2)
-
- // Verify parent-child relationships
- expect(rootNode.mainChild).toBe(node1)
- expect(node1.mainChild).toBe(node2)
- expect(node2.mainChild).toBeNull()
- })
- })
-
- describe('FEN position detection', () => {
- it('should properly detect custom FEN vs starting position', () => {
- const startingFen =
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
-
- const startingTree = new GameTree(startingFen)
- const customTree = new GameTree(customFen)
-
- // Starting position should not have FEN header
- expect(startingTree.getHeader('FEN')).toBeUndefined()
- expect(startingTree.getHeader('SetUp')).toBeUndefined()
-
- // Custom FEN should have headers
- expect(customTree.getHeader('FEN')).toBe(customFen)
- expect(customTree.getHeader('SetUp')).toBe('1')
- })
- })
-})
diff --git a/__tests__/types/tree.test.ts b/__tests__/types/tree.test.ts
deleted file mode 100644
index 2e03552b..00000000
--- a/__tests__/types/tree.test.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import { GameNode } from 'src/types/base/tree'
-import { StockfishEvaluation, MaiaEvaluation } from 'src/types'
-
-describe('GameNode Move Classification', () => {
- describe('Excellent Move Criteria', () => {
- it('should classify move as excellent when Maia probability < 10% and winrate is 10% higher than weighted average', () => {
- const parentNode = new GameNode(
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
- )
-
- // Mock Stockfish evaluation with winrate vectors
- const stockfishEval: StockfishEvaluation = {
- sent: true,
- depth: 15,
- model_move: 'e2e4',
- model_optimal_cp: 50,
- cp_vec: { e2e4: 50, d2d4: 40, g1f3: 30 },
- cp_relative_vec: { e2e4: 0, d2d4: -10, g1f3: -20 },
- winrate_vec: { e2e4: 0.6, d2d4: 0.58, g1f3: 0.4 },
- winrate_loss_vec: { e2e4: 0, d2d4: -0.02, g1f3: -0.2 },
- }
-
- // Mock Maia evaluation with policy probabilities
- const maiaEval: { [rating: string]: MaiaEvaluation } = {
- maia_kdd_1500: {
- policy: {
- e2e4: 0.5, // 50% probability - most likely move
- d2d4: 0.3, // 30% probability
- g1f3: 0.05, // 5% probability - less than 10% threshold
- },
- value: 0.6,
- },
- }
-
- // Add analysis to parent node
- parentNode.addStockfishAnalysis(stockfishEval, 'maia_kdd_1500')
- parentNode.addMaiaAnalysis(maiaEval, 'maia_kdd_1500')
-
- // Calculate weighted average manually for verification:
- // weighted_avg = (0.5 * 0.6 + 0.3 * 0.58 + 0.05 * 0.4) / (0.5 + 0.3 + 0.05)
- // weighted_avg = (0.3 + 0.174 + 0.02) / 0.85 = 0.494 / 0.85 ≈ 0.581
- // g1f3 winrate (0.4) is NOT 10% higher than weighted average (0.581)
- // So g1f3 should NOT be excellent despite low Maia probability
-
- // Test move with low Maia probability but not high enough winrate
- const classificationG1f3 = GameNode.classifyMove(
- parentNode,
- 'g1f3',
- 'maia_kdd_1500',
- )
- expect(classificationG1f3.excellent).toBe(false)
-
- // Now test with a different scenario where a move has both low probability and high winrate
- const stockfishEval2: StockfishEvaluation = {
- sent: true,
- depth: 15,
- model_move: 'e2e4',
- model_optimal_cp: 50,
- cp_vec: { e2e4: 50, d2d4: 40, b1c3: 45 },
- cp_relative_vec: { e2e4: 0, d2d4: -10, b1c3: -5 },
- winrate_vec: { e2e4: 0.6, d2d4: 0.45, b1c3: 0.7 },
- winrate_loss_vec: { e2e4: 0, d2d4: -0.15, b1c3: 0.1 },
- }
-
- const maiaEval2: { [rating: string]: MaiaEvaluation } = {
- maia_kdd_1500: {
- policy: {
- e2e4: 0.6, // 60% probability
- d2d4: 0.35, // 35% probability
- b1c3: 0.05, // 5% probability - less than 10% threshold
- },
- value: 0.6,
- },
- }
-
- const parentNode2 = new GameNode(
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
- )
- parentNode2.addStockfishAnalysis(stockfishEval2, 'maia_kdd_1500')
- parentNode2.addMaiaAnalysis(maiaEval2, 'maia_kdd_1500')
-
- // Calculate weighted average: (0.6 * 0.6 + 0.35 * 0.45 + 0.05 * 0.7) / 1.0
- // = (0.36 + 0.1575 + 0.035) / 1.0 = 0.5525
- // b1c3 winrate (0.7) is about 14.75% higher than weighted average (0.5525)
- // So b1c3 should be excellent (low Maia probability AND high relative winrate)
-
- const classificationB1c3 = GameNode.classifyMove(
- parentNode2,
- 'b1c3',
- 'maia_kdd_1500',
- )
- expect(classificationB1c3.excellent).toBe(true)
- })
-
- it('should not classify move as excellent when Maia probability >= 10%', () => {
- const parentNode = new GameNode(
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
- )
-
- const stockfishEval: StockfishEvaluation = {
- sent: true,
- depth: 15,
- model_move: 'e2e4',
- model_optimal_cp: 50,
- cp_vec: { e2e4: 50, d2d4: 40 },
- cp_relative_vec: { e2e4: 0, d2d4: -10 },
- winrate_vec: { e2e4: 0.6, d2d4: 0.7 },
- winrate_loss_vec: { e2e4: 0, d2d4: 0.1 },
- }
-
- const maiaEval: { [rating: string]: MaiaEvaluation } = {
- maia_kdd_1500: {
- policy: {
- e2e4: 0.8, // 80% probability - above 10% threshold
- d2d4: 0.2, // 20% probability - above 10% threshold
- },
- value: 0.6,
- },
- }
-
- parentNode.addStockfishAnalysis(stockfishEval, 'maia_kdd_1500')
- parentNode.addMaiaAnalysis(maiaEval, 'maia_kdd_1500')
-
- // Even though d2d4 has higher winrate than weighted average,
- // it should not be excellent because Maia probability > 10%
- const classification = GameNode.classifyMove(
- parentNode,
- 'd2d4',
- 'maia_kdd_1500',
- )
- expect(classification.excellent).toBe(false)
- })
-
- it('should not classify move as excellent when winrate advantage < 10%', () => {
- const parentNode = new GameNode(
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
- )
-
- const stockfishEval: StockfishEvaluation = {
- sent: true,
- depth: 15,
- model_move: 'e2e4',
- model_optimal_cp: 50,
- cp_vec: { e2e4: 50, d2d4: 40, a2a3: 20 },
- cp_relative_vec: { e2e4: 0, d2d4: -10, a2a3: -30 },
- winrate_vec: { e2e4: 0.6, d2d4: 0.55, a2a3: 0.62 },
- winrate_loss_vec: { e2e4: 0, d2d4: -0.05, a2a3: 0.02 },
- }
-
- const maiaEval: { [rating: string]: MaiaEvaluation } = {
- maia_kdd_1500: {
- policy: {
- e2e4: 0.7, // 70% probability
- d2d4: 0.25, // 25% probability
- a2a3: 0.05, // 5% probability - below 10% threshold
- },
- value: 0.6,
- },
- }
-
- parentNode.addStockfishAnalysis(stockfishEval, 'maia_kdd_1500')
- parentNode.addMaiaAnalysis(maiaEval, 'maia_kdd_1500')
-
- // Weighted average: (0.7 * 0.6 + 0.25 * 0.55 + 0.05 * 0.62) / 1.0
- // = (0.42 + 0.1375 + 0.031) / 1.0 = 0.5885
- // a2a3 winrate (0.62) is only about 3.15% higher than weighted average
- // So a2a3 should NOT be excellent (advantage < 10%)
-
- const classification = GameNode.classifyMove(
- parentNode,
- 'a2a3',
- 'maia_kdd_1500',
- )
- expect(classification.excellent).toBe(false)
- })
- })
-})
diff --git a/package-lock.json b/package-lock.json
index 02d090b7..ad38d228 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,7 +19,7 @@
"lila-stockfish-web": "^0.0.7",
"next": "^15.2.3",
"next-transpile-modules": "^10.0.1",
- "onnxruntime-web": "^1.21.0-dev.20250114-228dd16893",
+ "onnxruntime-web": "^1.23.0",
"posthog-js": "^1.257.0",
"posthog-node": "^5.3.1",
"react": "^18.3.1",
@@ -45,12 +45,13 @@
"@types/node": "17.0.8",
"@types/react": "19.0.8",
"@types/react-dom": "^19.1.6",
- "@typescript-eslint/eslint-plugin": "^5.9.1",
+ "@typescript-eslint/eslint-plugin": "^8.15.0",
+ "@typescript-eslint/parser": "^8.15.0",
"autoprefixer": "^10.4.20",
"babel-loader": "^8.2.3",
- "eslint": "8.6.0",
+ "eslint": "^8.57.0",
"eslint-config-next": "15.1.6",
- "eslint-config-prettier": "^8.3.0",
+ "eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^5.0.0",
@@ -63,7 +64,7 @@
"prettier-plugin-tailwindcss": "^0.6.6",
"sass-loader": "^12.4.0",
"tailwindcss": "^3.4.10",
- "typescript": "^5.1.6"
+ "typescript": "^5.8.3"
}
},
"node_modules/@adobe/css-tools": {
@@ -824,7 +825,6 @@
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
"integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
"dev": true,
- "license": "MIT",
"dependencies": {
"eslint-visitor-keys": "^3.4.3"
},
@@ -849,15 +849,14 @@
}
},
"node_modules/@eslint/eslintrc": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz",
- "integrity": "sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
- "espree": "^9.4.0",
+ "espree": "^9.6.0",
"globals": "^13.19.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
@@ -872,6 +871,15 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/@eslint/js": {
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
+ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
+ "dev": true,
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
"node_modules/@floating-ui/core": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz",
@@ -904,28 +912,39 @@
"license": "MIT"
},
"node_modules/@humanwhocodes/config-array": {
- "version": "0.9.5",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
- "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==",
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
+ "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
"deprecated": "Use @eslint/config-array instead",
"dev": true,
- "license": "Apache-2.0",
"dependencies": {
- "@humanwhocodes/object-schema": "^1.2.1",
- "debug": "^4.1.1",
- "minimatch": "^3.0.4"
+ "@humanwhocodes/object-schema": "^2.0.3",
+ "debug": "^4.3.1",
+ "minimatch": "^3.0.5"
},
"engines": {
"node": ">=10.10.0"
}
},
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
"node_modules/@humanwhocodes/object-schema": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
- "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
+ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"deprecated": "Use @eslint/object-schema instead",
- "dev": true,
- "license": "BSD-3-Clause"
+ "dev": true
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.3",
@@ -2382,13 +2401,6 @@
"@types/react": "^19.0.0"
}
},
- "node_modules/@types/semver": {
- "version": "7.7.0",
- "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz",
- "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -2427,135 +2439,152 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "5.62.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
- "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
+ "version": "8.39.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz",
+ "integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@eslint-community/regexpp": "^4.4.0",
- "@typescript-eslint/scope-manager": "5.62.0",
- "@typescript-eslint/type-utils": "5.62.0",
- "@typescript-eslint/utils": "5.62.0",
- "debug": "^4.3.4",
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "8.39.0",
+ "@typescript-eslint/type-utils": "8.39.0",
+ "@typescript-eslint/utils": "8.39.0",
+ "@typescript-eslint/visitor-keys": "8.39.0",
"graphemer": "^1.4.0",
- "ignore": "^5.2.0",
- "natural-compare-lite": "^1.4.0",
- "semver": "^7.3.7",
- "tsutils": "^3.21.0"
+ "ignore": "^7.0.0",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.1.0"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^5.0.0",
- "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
+ "@typescript-eslint/parser": "^8.39.0",
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
"engines": {
- "node": ">=10"
+ "node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "5.62.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
- "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
+ "version": "8.39.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.0.tgz",
+ "integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==",
"dev": true,
- "license": "BSD-2-Clause",
"dependencies": {
- "@typescript-eslint/scope-manager": "5.62.0",
- "@typescript-eslint/types": "5.62.0",
- "@typescript-eslint/typescript-estree": "5.62.0",
+ "@typescript-eslint/scope-manager": "8.39.0",
+ "@typescript-eslint/types": "8.39.0",
+ "@typescript-eslint/typescript-estree": "8.39.0",
+ "@typescript-eslint/visitor-keys": "8.39.0",
"debug": "^4.3.4"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.39.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.0.tgz",
+ "integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==",
+ "dev": true,
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.39.0",
+ "@typescript-eslint/types": "^8.39.0",
+ "debug": "^4.3.4"
},
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "5.62.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
- "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==",
+ "version": "8.39.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz",
+ "integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "5.62.0",
- "@typescript-eslint/visitor-keys": "5.62.0"
+ "@typescript-eslint/types": "8.39.0",
+ "@typescript-eslint/visitor-keys": "8.39.0"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.39.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz",
+ "integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==",
+ "dev": true,
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
"node_modules/@typescript-eslint/type-utils": {
- "version": "5.62.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz",
- "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==",
+ "version": "8.39.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.0.tgz",
+ "integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@typescript-eslint/typescript-estree": "5.62.0",
- "@typescript-eslint/utils": "5.62.0",
+ "@typescript-eslint/types": "8.39.0",
+ "@typescript-eslint/typescript-estree": "8.39.0",
+ "@typescript-eslint/utils": "8.39.0",
"debug": "^4.3.4",
- "tsutils": "^3.21.0"
+ "ts-api-utils": "^2.1.0"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "*"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
- "version": "5.62.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
- "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==",
+ "version": "8.39.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.0.tgz",
+ "integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==",
"dev": true,
- "license": "MIT",
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
@@ -2563,31 +2592,83 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "5.62.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz",
- "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==",
+ "version": "8.39.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz",
+ "integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==",
"dev": true,
- "license": "BSD-2-Clause",
"dependencies": {
- "@typescript-eslint/types": "5.62.0",
- "@typescript-eslint/visitor-keys": "5.62.0",
+ "@typescript-eslint/project-service": "8.39.0",
+ "@typescript-eslint/tsconfig-utils": "8.39.0",
+ "@typescript-eslint/types": "8.39.0",
+ "@typescript-eslint/visitor-keys": "8.39.0",
"debug": "^4.3.4",
- "globby": "^11.1.0",
+ "fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
- "semver": "^7.3.7",
- "tsutils": "^3.21.0"
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^2.1.0"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
@@ -2595,7 +2676,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
- "license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
@@ -2604,63 +2684,57 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "5.62.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz",
- "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==",
+ "version": "8.39.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.0.tgz",
+ "integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.2.0",
- "@types/json-schema": "^7.0.9",
- "@types/semver": "^7.3.12",
- "@typescript-eslint/scope-manager": "5.62.0",
- "@typescript-eslint/types": "5.62.0",
- "@typescript-eslint/typescript-estree": "5.62.0",
- "eslint-scope": "^5.1.1",
- "semver": "^7.3.7"
+ "@eslint-community/eslint-utils": "^4.7.0",
+ "@typescript-eslint/scope-manager": "8.39.0",
+ "@typescript-eslint/types": "8.39.0",
+ "@typescript-eslint/typescript-estree": "8.39.0"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
- }
- },
- "node_modules/@typescript-eslint/utils/node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "5.62.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz",
- "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==",
+ "version": "8.39.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz",
+ "integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "5.62.0",
- "eslint-visitor-keys": "^3.3.0"
+ "@typescript-eslint/types": "8.39.0",
+ "eslint-visitor-keys": "^4.2.1"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -2957,7 +3031,6 @@
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true,
- "license": "MIT",
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
@@ -3044,16 +3117,6 @@
"ajv": "^6.9.1"
}
},
- "node_modules/ansi-colors": {
- "version": "4.1.3",
- "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
- "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@@ -3141,8 +3204,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "dev": true,
- "license": "Python-2.0"
+ "dev": true
},
"node_modules/aria-query": {
"version": "5.3.0",
@@ -3194,16 +3256,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/array-union": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
- "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/array.prototype.findlast": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
@@ -4546,19 +4598,6 @@
"dev": true,
"license": "Apache-2.0"
},
- "node_modules/dir-glob": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
- "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "path-type": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -4668,20 +4707,6 @@
"node": ">=10.13.0"
}
},
- "node_modules/enquirer": {
- "version": "2.4.1",
- "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz",
- "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-colors": "^4.1.1",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8.6"
- }
- },
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -4913,51 +4938,50 @@
}
},
"node_modules/eslint": {
- "version": "8.6.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.6.0.tgz",
- "integrity": "sha512-UvxdOJ7mXFlw7iuHZA4jmzPaUqIw54mZrv+XPYKNbKdLR0et4rf60lIZUU9kiNtnzzMzGWxMV+tQ7uG7JG8DPw==",
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
+ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@eslint/eslintrc": "^1.0.5",
- "@humanwhocodes/config-array": "^0.9.2",
- "ajv": "^6.10.0",
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.6.1",
+ "@eslint/eslintrc": "^2.1.4",
+ "@eslint/js": "8.57.1",
+ "@humanwhocodes/config-array": "^0.13.0",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@nodelib/fs.walk": "^1.2.8",
+ "@ungap/structured-clone": "^1.2.0",
+ "ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.2",
"debug": "^4.3.2",
"doctrine": "^3.0.0",
- "enquirer": "^2.3.5",
"escape-string-regexp": "^4.0.0",
- "eslint-scope": "^7.1.0",
- "eslint-utils": "^3.0.0",
- "eslint-visitor-keys": "^3.1.0",
- "espree": "^9.3.0",
- "esquery": "^1.4.0",
+ "eslint-scope": "^7.2.2",
+ "eslint-visitor-keys": "^3.4.3",
+ "espree": "^9.6.1",
+ "esquery": "^1.4.2",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "^6.0.1",
- "functional-red-black-tree": "^1.0.1",
- "glob-parent": "^6.0.1",
- "globals": "^13.6.0",
- "ignore": "^4.0.6",
- "import-fresh": "^3.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "globals": "^13.19.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
+ "is-path-inside": "^3.0.3",
"js-yaml": "^4.1.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"levn": "^0.4.1",
"lodash.merge": "^4.6.2",
- "minimatch": "^3.0.4",
+ "minimatch": "^3.1.2",
"natural-compare": "^1.4.0",
- "optionator": "^0.9.1",
- "progress": "^2.0.0",
- "regexpp": "^3.2.0",
- "semver": "^7.2.1",
+ "optionator": "^0.9.3",
"strip-ansi": "^6.0.1",
- "strip-json-comments": "^3.1.0",
- "text-table": "^0.2.0",
- "v8-compile-cache": "^2.0.3"
+ "text-table": "^0.2.0"
},
"bin": {
"eslint": "bin/eslint.js"
@@ -5033,11 +5057,10 @@
}
},
"node_modules/eslint-config-prettier": {
- "version": "8.10.2",
- "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.2.tgz",
- "integrity": "sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A==",
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz",
+ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==",
"dev": true,
- "license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -5306,6 +5329,7 @@
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true,
"license": "BSD-2-Clause",
+ "peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
@@ -5320,39 +5344,11 @@
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true,
"license": "BSD-2-Clause",
+ "peer": true,
"engines": {
"node": ">=4.0"
}
},
- "node_modules/eslint-utils": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
- "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eslint-visitor-keys": "^2.0.0"
- },
- "engines": {
- "node": "^10.0.0 || ^12.0.0 || >= 14.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/mysticatea"
- },
- "peerDependencies": {
- "eslint": ">=5"
- }
- },
- "node_modules/eslint-utils/node_modules/eslint-visitor-keys": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
- "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
@@ -5383,27 +5379,50 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/eslint/node_modules/ignore": {
- "version": "4.0.6",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
- "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
+ "node_modules/eslint/node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
- "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
"engines": {
- "node": ">= 4"
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/eslint/node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "node_modules/eslint/node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
+ "dependencies": {
+ "p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint/node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/espree": {
@@ -5411,7 +5430,6 @@
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"dev": true,
- "license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.9.0",
"acorn-jsx": "^5.3.2",
@@ -5927,13 +5945,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/functional-red-black-tree": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
- "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/functions-have-names": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
@@ -6129,7 +6140,6 @@
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
"type-fest": "^0.20.2"
},
@@ -6157,27 +6167,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/globby": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
- "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "array-union": "^2.1.0",
- "dir-glob": "^3.0.1",
- "fast-glob": "^3.2.9",
- "ignore": "^5.2.0",
- "merge2": "^1.4.1",
- "slash": "^3.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/goober": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
@@ -6492,7 +6481,6 @@
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
@@ -6961,6 +6949,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-path-inside": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
@@ -8266,7 +8263,6 @@
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
- "license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
@@ -9450,13 +9446,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/natural-compare-lite": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz",
- "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -9816,21 +9805,19 @@
}
},
"node_modules/onnxruntime-common": {
- "version": "1.22.0",
- "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0.tgz",
- "integrity": "sha512-vcuaNWgtF2dGQu/EP5P8UI5rEPEYqXG2sPPe5j9lg2TY/biJF8eWklTMwlDO08iuXq48xJo0awqIpK5mPG+IxA==",
- "license": "MIT"
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.23.0.tgz",
+ "integrity": "sha512-Auz8S9D7vpF8ok7fzTobvD1XdQDftRf/S7pHmjeCr3Xdymi4z1C7zx4vnT6nnUjbpelZdGwda0BmWHCCTMKUTg=="
},
"node_modules/onnxruntime-web": {
- "version": "1.22.0",
- "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0.tgz",
- "integrity": "sha512-Ud/+EBo6mhuaQWt/OjaOk0iNWjXqJoeeMFr6xQEERZdIZH2OWpGzuujz7lfuOBjUa6TEE/sc4nb7Da5dNL34fg==",
- "license": "MIT",
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.23.0.tgz",
+ "integrity": "sha512-w0bvC2RwDxphOUFF8jFGZ/dYw+duaX20jM6V4BIZJPCfK4QuCpB/pVREV+hjYbT3x4hyfa2ZbTaWx4e1Vot0fQ==",
"dependencies": {
"flatbuffers": "^25.1.24",
"guid-typescript": "^1.0.9",
"long": "^5.2.3",
- "onnxruntime-common": "1.22.0",
+ "onnxruntime-common": "1.23.0",
"platform": "^1.3.6",
"protobufjs": "^7.2.4"
}
@@ -9938,7 +9925,6 @@
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true,
- "license": "MIT",
"dependencies": {
"callsites": "^3.0.0"
},
@@ -10074,16 +10060,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/path-type": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
- "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -10490,16 +10466,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/progress": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
- "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.4.0"
- }
- },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -10992,19 +10958,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/regexpp": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
- "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/mysticatea"
- }
- },
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -11108,7 +11061,6 @@
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=4"
}
@@ -12757,6 +12709,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/ts-api-utils": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
+ "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -12806,29 +12770,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
- "node_modules/tsutils": {
- "version": "3.21.0",
- "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
- "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tslib": "^1.8.1"
- },
- "engines": {
- "node": ">= 6"
- },
- "peerDependencies": {
- "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
- }
- },
- "node_modules/tsutils/node_modules/tslib": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
- "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
- "dev": true,
- "license": "0BSD"
- },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -12857,7 +12798,6 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"dev": true,
- "license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
@@ -13159,13 +13099,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/v8-compile-cache": {
- "version": "2.4.0",
- "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz",
- "integrity": "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
diff --git a/package.json b/package.json
index 8af34220..88a51c41 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,7 @@
"lila-stockfish-web": "^0.0.7",
"next": "^15.2.3",
"next-transpile-modules": "^10.0.1",
- "onnxruntime-web": "^1.21.0-dev.20250114-228dd16893",
+ "onnxruntime-web": "^1.23.0",
"posthog-js": "^1.257.0",
"posthog-node": "^5.3.1",
"react": "^18.3.1",
@@ -49,12 +49,13 @@
"@types/node": "17.0.8",
"@types/react": "19.0.8",
"@types/react-dom": "^19.1.6",
- "@typescript-eslint/eslint-plugin": "^5.9.1",
+ "@typescript-eslint/eslint-plugin": "^8.15.0",
+ "@typescript-eslint/parser": "^8.15.0",
"autoprefixer": "^10.4.20",
"babel-loader": "^8.2.3",
- "eslint": "8.6.0",
+ "eslint": "^8.57.0",
"eslint-config-next": "15.1.6",
- "eslint-config-prettier": "^8.3.0",
+ "eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^5.0.0",
@@ -67,7 +68,7 @@
"prettier-plugin-tailwindcss": "^0.6.6",
"sass-loader": "^12.4.0",
"tailwindcss": "^3.4.10",
- "typescript": "^5.1.6"
+ "typescript": "^5.8.3"
},
"overrides": {
"@types/react": "19.0.8"
diff --git a/public/assets/icons/chessdotcom.svg b/public/assets/icons/chessdotcom.svg
new file mode 100644
index 00000000..9a9ec9c6
--- /dev/null
+++ b/public/assets/icons/chessdotcom.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/assets/icons/lichess.svg b/public/assets/icons/lichess.svg
new file mode 100644
index 00000000..f51190f7
--- /dev/null
+++ b/public/assets/icons/lichess.svg
@@ -0,0 +1,4 @@
+
+
+
diff --git a/public/embed.png b/public/embed.png
new file mode 100644
index 00000000..aa2e6317
Binary files /dev/null and b/public/embed.png differ
diff --git a/src/api/analysis.ts b/src/api/analysis.ts
new file mode 100644
index 00000000..efd100d4
--- /dev/null
+++ b/src/api/analysis.ts
@@ -0,0 +1,376 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import {
+ Player,
+ AnalyzedGame,
+ MoveValueMapping,
+ CachedEngineAnalysisEntry,
+ WorldChampionshipGameListEntry,
+ RawMove,
+} from 'src/types'
+import {
+ readLichessStream,
+ buildGameTreeFromMoveList,
+ buildMovesListFromGameStates,
+ insertBackendStockfishEvalToGameTree,
+} from 'src/lib'
+import { buildUrl } from './utils'
+import { AvailableMoves } from 'src/types/puzzle'
+import { Chess } from 'chess.ts'
+
+export const fetchWorldChampionshipGameList = async (): Promise<
+ Map
+> => {
+ const res = await fetch(buildUrl('analysis/list'))
+ const data = await res.json()
+
+ return data
+}
+
+export const fetchMaiaGameList = async (
+ type = 'play',
+ page = 1,
+ lichessId?: string,
+) => {
+ const url = buildUrl(`analysis/user/list/${type}/${page}`)
+ const searchParams = new URLSearchParams()
+
+ if (lichessId) {
+ searchParams.append('lichess_id', lichessId)
+ }
+
+ const fullUrl = searchParams.toString()
+ ? `${url}?${searchParams.toString()}`
+ : url
+ const res = await fetch(fullUrl)
+
+ const data = await res.json()
+
+ return data
+}
+
+export const streamLichessGames = async (
+ username: string,
+ onMessage: (data: any) => void,
+) => {
+ const stream = fetch(
+ `https://lichess.org/api/games/user/${username}?max=100&pgnInJson=true`,
+ {
+ headers: {
+ Accept: 'application/x-ndjson',
+ },
+ },
+ )
+ stream.then(readLichessStream(onMessage))
+}
+
+export const fetchPgnOfLichessGame = async (id: string): Promise => {
+ const res = await fetch(`https://lichess.org/game/export/${id}`, {
+ headers: {
+ Accept: 'application/x-chess-pgn',
+ },
+ })
+ return res.text()
+}
+
+export const fetchAnalyzedWorldChampionshipGame = async (
+ gameId = ['FkgYSri1'],
+) => {
+ const res = await fetch(
+ buildUrl(`analysis/analysis_list/${gameId.join('/')}`),
+ )
+
+ const data = await res.json()
+
+ const id = data['id']
+ const termination = {
+ ...data['termination'],
+ condition: 'Normal',
+ }
+
+ const gameType = 'blitz'
+ const blackPlayer = data['black_player'] as Player
+ const whitePlayer = data['white_player'] as Player
+
+ const maiaEvals: {
+ [model: string]: MoveValueMapping[]
+ } = {}
+ const stockfishEvaluations: MoveValueMapping[] = data['stockfish_evals']
+
+ const availableMoves: AvailableMoves[] = []
+
+ for (const model of data['maia_versions']) {
+ maiaEvals[model] = data['maia_evals'][model]
+ }
+
+ for (const position of data['move_maps']) {
+ const moves: AvailableMoves = {}
+ for (const move of position) {
+ const fromTo = move.move.join('')
+ const san = move['move_san']
+ const { check, fen } = move
+
+ moves[fromTo] = {
+ board: fen,
+ check,
+ san,
+ lastMove: move.move,
+ } as RawMove
+ }
+ availableMoves.push(moves)
+ }
+
+ const gameStates = data['game_states']
+
+ const moves = buildMovesListFromGameStates(gameStates)
+ const tree = buildGameTreeFromMoveList(moves, moves[0].board)
+ insertBackendStockfishEvalToGameTree(tree, moves, stockfishEvaluations)
+
+ return {
+ id,
+ blackPlayer,
+ whitePlayer,
+ availableMoves,
+ gameType,
+ termination,
+ tree,
+ } as AnalyzedGame
+}
+
+export const fetchAnalyzedPgnGame = async (id: string, pgn: string) => {
+ const res = await fetch(buildUrl('analysis/analyze_user_game'), {
+ method: 'POST',
+ body: pgn,
+ headers: {
+ 'Content-Type': 'text/plain',
+ },
+ })
+
+ const data = await res.json()
+
+ const termination = {
+ ...data['termination'],
+ condition: 'Normal',
+ }
+
+ const gameType = 'blitz'
+ const blackPlayer = data['black_player'] as Player
+ const whitePlayer = data['white_player'] as Player
+
+ const maiaEvals: { [model: string]: MoveValueMapping[] } = {}
+ const availableMoves: AvailableMoves[] = []
+
+ for (const model of data['maia_versions']) {
+ maiaEvals[model] = data['maia_evals'][model]
+ }
+
+ for (const position of data['move_maps']) {
+ const moves: AvailableMoves = {}
+ for (const move of position) {
+ const fromTo = move.move.join('')
+ const san = move['move_san']
+ const { check, fen } = move
+
+ moves[fromTo] = {
+ board: fen,
+ check,
+ san,
+ lastMove: move.move,
+ } as RawMove
+ }
+ availableMoves.push(moves)
+ }
+
+ const gameStates = data['game_states']
+
+ const moves = buildMovesListFromGameStates(gameStates)
+ const tree = buildGameTreeFromMoveList(moves, moves[0].board)
+
+ return {
+ id,
+ blackPlayer,
+ whitePlayer,
+ availableMoves,
+ gameType,
+ termination,
+ tree,
+ } as AnalyzedGame
+}
+
+export const fetchAnalyzedMaiaGame = async (
+ id: string,
+ game_type: 'play' | 'hand' | 'brain' | 'custom',
+) => {
+ const res = await fetch(
+ buildUrl(
+ `analysis/user/analyze_user_maia_game/${id}?` +
+ new URLSearchParams({
+ game_type,
+ }),
+ ),
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'text/plain',
+ },
+ },
+ )
+
+ const data = await res.json()
+
+ const termination = {
+ ...data['termination'],
+ condition: 'Normal',
+ }
+
+ const gameType = 'blitz'
+ const blackPlayer = data['black_player'] as Player
+ const whitePlayer = data['white_player'] as Player
+
+ const maiaPattern = /maia_kdd_1\d00/
+
+ if (blackPlayer.name && maiaPattern.test(blackPlayer.name)) {
+ blackPlayer.name = blackPlayer.name.replace('maia_kdd_', 'Maia ')
+ }
+
+ if (whitePlayer.name && maiaPattern.test(whitePlayer.name)) {
+ whitePlayer.name = whitePlayer.name.replace('maia_kdd_', 'Maia ')
+ }
+
+ const maiaEvals: { [model: string]: MoveValueMapping[] } = {}
+ const availableMoves: AvailableMoves[] = []
+
+ for (const model of data['maia_versions']) {
+ maiaEvals[model] = data['maia_evals'][model]
+ }
+
+ for (const position of data['move_maps']) {
+ const moves: AvailableMoves = {}
+ for (const move of position) {
+ const fromTo = move.move.join('')
+ const san = move['move_san']
+ const { check, fen } = move
+
+ moves[fromTo] = {
+ board: fen,
+ check,
+ san,
+ lastMove: move.move,
+ } as RawMove
+ }
+ availableMoves.push(moves)
+ }
+
+ const gameStates = data['game_states']
+
+ const moves = buildMovesListFromGameStates(gameStates)
+ const tree = buildGameTreeFromMoveList(
+ moves,
+ moves.length ? moves[0].board : new Chess().fen(),
+ )
+
+ return {
+ id,
+ type: game_type,
+ blackPlayer,
+ whitePlayer,
+ moves,
+ availableMoves,
+ gameType,
+ termination,
+ tree,
+ } as AnalyzedGame
+}
+
+export const storeGameAnalysisCache = async (
+ gameId: string,
+ analysisData: CachedEngineAnalysisEntry[],
+): Promise => {
+ const res = await fetch(
+ buildUrl(`analysis/store_engine_analysis/${gameId}`),
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(analysisData),
+ },
+ )
+
+ if (!res.ok) {
+ console.error('Failed to cache engine analysis')
+ }
+}
+
+export const retrieveGameAnalysisCache = async (
+ gameId: string,
+): Promise<{ positions: CachedEngineAnalysisEntry[] } | null> => {
+ const res = await fetch(buildUrl(`analysis/get_engine_analysis/${gameId}`))
+
+ if (res.status === 404) {
+ return null
+ }
+
+ if (!res.ok) {
+ console.error('Failed to retrieve engine analysis')
+ }
+
+ const data = await res.json()
+
+ return data
+}
+
+export const updateGameMetadata = async (
+ gameType: 'custom' | 'play' | 'hand' | 'brain',
+ gameId: string,
+ metadata: {
+ custom_name?: string
+ is_favorited?: boolean
+ },
+): Promise => {
+ const res = await fetch(
+ buildUrl(`analysis/update_metadata/${gameType}/${gameId}`),
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(metadata),
+ },
+ )
+
+ if (!res.ok) {
+ console.error('Failed to update game metadata')
+ }
+}
+
+export const storeCustomGame = async (data: {
+ name?: string
+ pgn?: string
+ fen?: string
+}): Promise<{
+ game_id: string
+}> => {
+ const res = await fetch(buildUrl('analysis/store_custom_game'), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(data),
+ })
+
+ if (!res.ok) {
+ console.error(`Failed to store custom game: ${await res.text()}`)
+ }
+
+ return res.json()
+}
+
+export const deleteCustomGame = async (gameId: string): Promise => {
+ const res = await fetch(buildUrl(`analysis/delete_custom_game/${gameId}`), {
+ method: 'DELETE',
+ })
+
+ if (!res.ok) {
+ console.error(`Failed to delete custom game: ${await res.text()}`)
+ }
+}
diff --git a/src/api/analysis/analysis.ts b/src/api/analysis/analysis.ts
deleted file mode 100644
index 16ea177e..00000000
--- a/src/api/analysis/analysis.ts
+++ /dev/null
@@ -1,720 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import {
- Player,
- MoveMap,
- GameTree,
- GameNode,
- AnalyzedGame,
- MaiaEvaluation,
- PositionEvaluation,
- StockfishEvaluation,
- AnalysisTournamentGame,
-} from 'src/types'
-import { buildUrl } from '../utils'
-import { cpToWinrate } from 'src/lib/stockfish'
-import { AvailableMoves } from 'src/types/training'
-
-function buildGameTree(moves: any[], initialFen: string) {
- const tree = new GameTree(initialFen)
- let currentNode = tree.getRoot()
-
- for (let i = 0; i < moves.length; i++) {
- const move = moves[i]
-
- if (move.lastMove) {
- const [from, to] = move.lastMove
- currentNode = tree.addMainMove(
- currentNode,
- move.board,
- from + to,
- move.san || '',
- )
- }
- }
-
- return tree
-}
-
-const readStream = (processLine: (data: any) => void) => (response: any) => {
- const stream = response.body.getReader()
- const matcher = /\r?\n/
- const decoder = new TextDecoder()
- let buf = ''
-
- const loop = () =>
- stream.read().then(({ done, value }: { done: boolean; value: any }) => {
- if (done) {
- if (buf.length > 0) processLine(JSON.parse(buf))
- } else {
- const chunk = decoder.decode(value, {
- stream: true,
- })
- buf += chunk
-
- const parts = (buf || '').split(matcher)
- buf = parts.pop() as string
- for (const i of parts.filter((p) => p)) processLine(JSON.parse(i))
-
- return loop()
- }
- })
-
- return loop()
-}
-
-export const getAnalysisList = async (): Promise<
- Map
-> => {
- const res = await fetch(buildUrl('analysis/list'))
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- const data = await res.json()
-
- return data
-}
-
-export const getAnalysisGameList = async (
- type = 'play',
- page = 1,
- lichessId?: string,
-) => {
- const url = buildUrl(`analysis/user/list/${type}/${page}`)
- const searchParams = new URLSearchParams()
-
- if (lichessId) {
- searchParams.append('lichess_id', lichessId)
- }
-
- const fullUrl = searchParams.toString()
- ? `${url}?${searchParams.toString()}`
- : url
- const res = await fetch(fullUrl)
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- const data = await res.json()
-
- return data
-}
-
-export const getLichessGames = async (
- username: string,
- onMessage: (data: any) => void,
-) => {
- const stream = fetch(
- `https://lichess.org/api/games/user/${username}?max=100&pgnInJson=true`,
- {
- headers: {
- Accept: 'application/x-ndjson',
- },
- },
- )
- stream.then(readStream(onMessage))
-}
-
-export const getLichessGamePGN = async (id: string) => {
- const res = await fetch(`https://lichess.org/game/export/${id}`, {
- headers: {
- Accept: 'application/x-chess-pgn',
- },
- })
- return res.text()
-}
-
-function convertMoveMapToStockfishEval(
- moveMap: MoveMap,
- turn: 'w' | 'b',
-): StockfishEvaluation {
- const cp_vec: { [key: string]: number } = {}
- const cp_relative_vec: { [key: string]: number } = {}
- let model_optimal_cp = -Infinity
- let model_move = ''
-
- for (const move in moveMap) {
- const cp = moveMap[move]
- cp_vec[move] = cp
- if (cp > model_optimal_cp) {
- model_optimal_cp = cp
- model_move = move
- }
- }
-
- for (const move in cp_vec) {
- const cp = moveMap[move]
- cp_relative_vec[move] = cp - model_optimal_cp
- }
-
- const cp_vec_sorted = Object.fromEntries(
- Object.entries(cp_vec).sort(([, a], [, b]) => b - a),
- )
-
- const cp_relative_vec_sorted = Object.fromEntries(
- Object.entries(cp_relative_vec).sort(([, a], [, b]) => b - a),
- )
-
- const winrate_vec: { [key: string]: number } = {}
- let max_winrate = -Infinity
-
- for (const move in cp_vec_sorted) {
- const cp = cp_vec_sorted[move]
- const winrate = cpToWinrate(cp, false)
- winrate_vec[move] = winrate
-
- if (winrate_vec[move] > max_winrate) {
- max_winrate = winrate_vec[move]
- }
- }
-
- const winrate_loss_vec: { [key: string]: number } = {}
- for (const move in winrate_vec) {
- winrate_loss_vec[move] = winrate_vec[move] - max_winrate
- }
-
- const winrate_vec_sorted = Object.fromEntries(
- Object.entries(winrate_vec).sort(([, a], [, b]) => b - a),
- )
-
- const winrate_loss_vec_sorted = Object.fromEntries(
- Object.entries(winrate_loss_vec).sort(([, a], [, b]) => b - a),
- )
-
- if (turn === 'b') {
- model_optimal_cp *= -1
- for (const move in cp_vec_sorted) {
- cp_vec_sorted[move] *= -1
- }
- }
-
- return {
- sent: true,
- depth: 20,
- model_move: model_move,
- model_optimal_cp: model_optimal_cp,
- cp_vec: cp_vec_sorted,
- cp_relative_vec: cp_relative_vec_sorted,
- winrate_vec: winrate_vec_sorted,
- winrate_loss_vec: winrate_loss_vec_sorted,
- }
-}
-
-export const getAnalyzedTournamentGame = async (gameId = ['FkgYSri1']) => {
- const res = await fetch(
- buildUrl(`analysis/analysis_list/${gameId.join('/')}`),
- )
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- const data = await res.json()
- const id = data['id']
- const termination = {
- ...data['termination'],
- condition: 'Normal',
- }
-
- const gameType = 'blitz'
- const blackPlayer = data['black_player'] as Player
- const whitePlayer = data['white_player'] as Player
-
- const maiaEvals: { [model: string]: MoveMap[] } = {}
- const stockfishEvaluations: MoveMap[] = data['stockfish_evals']
-
- const availableMoves: AvailableMoves[] = []
-
- for (const model of data['maia_versions']) {
- maiaEvals[model] = data['maia_evals'][model]
- }
-
- for (const position of data['move_maps']) {
- const moves: AvailableMoves = {}
- for (const move of position) {
- const fromTo = move.move.join('')
- const san = move['move_san']
- const { check, fen } = move
-
- moves[fromTo] = {
- board: fen,
- check,
- san,
- lastMove: move.move,
- }
- }
- availableMoves.push(moves)
- }
-
- const gameStates = data['game_states']
-
- const moves = gameStates.map((gameState: any) => {
- const {
- last_move: lastMove,
- fen,
- check,
- last_move_san: san,
- evaluations: maia_values,
- } = gameState
-
- return {
- board: fen,
- lastMove,
- san,
- check,
- maia_values,
- }
- })
-
- const maiaEvaluations = [] as { [rating: number]: MaiaEvaluation }[]
-
- const tree = buildGameTree(moves, moves[0].board)
-
- let currentNode: GameNode | null = tree.getRoot()
-
- for (let i = 0; i < moves.length; i++) {
- if (!currentNode) {
- break
- }
-
- const stockfishEval = stockfishEvaluations[i]
- ? convertMoveMapToStockfishEval(
- stockfishEvaluations[i],
- moves[i].board.split(' ')[1],
- )
- : undefined
-
- if (stockfishEval) {
- currentNode.addStockfishAnalysis(stockfishEval)
- }
- currentNode = currentNode?.mainChild
- }
-
- return {
- id,
- blackPlayer,
- whitePlayer,
- moves,
- maiaEvaluations,
- stockfishEvaluations,
- availableMoves,
- gameType,
- termination,
- tree,
- } as any as AnalyzedGame
-}
-
-export const getAnalyzedLichessGame = async (id: string, pgn: string) => {
- const res = await fetch(buildUrl('analysis/analyze_user_game'), {
- method: 'POST',
- body: pgn,
- headers: {
- 'Content-Type': 'text/plain',
- },
- })
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- const data = await res.json()
-
- const termination = {
- ...data['termination'],
- condition: 'Normal',
- }
-
- const gameType = 'blitz'
- const blackPlayer = data['black_player'] as Player
- const whitePlayer = data['white_player'] as Player
-
- const maiaEvals: { [model: string]: MoveMap[] } = {}
- const positionEvaluations: { [model: string]: PositionEvaluation[] } = {}
- const availableMoves: AvailableMoves[] = []
-
- for (const model of data['maia_versions']) {
- maiaEvals[model] = data['maia_evals'][model]
- positionEvaluations[model] = Object.keys(data['maia_evals'][model]).map(
- () => ({
- trickiness: 1,
- performance: 1,
- }),
- )
- }
-
- for (const position of data['move_maps']) {
- const moves: AvailableMoves = {}
- for (const move of position) {
- const fromTo = move.move.join('')
- const san = move['move_san']
- const { check, fen } = move
-
- moves[fromTo] = {
- board: fen,
- check,
- san,
- lastMove: move.move,
- }
- }
- availableMoves.push(moves)
- }
-
- const gameStates = data['game_states']
-
- const moves = gameStates.map((gameState: any) => {
- const {
- last_move: lastMove,
- fen,
- check,
- last_move_san: san,
- evaluations: maia_values,
- } = gameState
-
- return {
- board: fen,
- lastMove,
- san,
- check,
- maia_values,
- }
- })
-
- const maiaEvaluations = [] as { [rating: number]: MaiaEvaluation }[]
- const stockfishEvaluations: StockfishEvaluation[] = []
- const tree = buildGameTree(moves, moves[0].board)
-
- return {
- id,
- blackPlayer,
- whitePlayer,
- moves,
- availableMoves,
- gameType,
- termination,
- maiaEvaluations,
- stockfishEvaluations,
- tree,
- type: 'brain',
- pgn,
- } as AnalyzedGame
-}
-
-const createAnalyzedGameFromPGN = async (
- pgn: string,
- id?: string,
-): Promise => {
- const { Chess } = await import('chess.ts')
- const chess = new Chess()
-
- try {
- chess.loadPgn(pgn)
- } catch (error) {
- throw new Error('Invalid PGN format')
- }
-
- const history = chess.history({ verbose: true })
- const headers = chess.header()
-
- const moves = []
- const tempChess = new Chess()
-
- const startingFen =
- headers.FEN || 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
- if (headers.FEN) {
- tempChess.load(headers.FEN)
- }
-
- moves.push({
- board: tempChess.fen(),
- lastMove: undefined,
- san: undefined,
- check: tempChess.inCheck(),
- maia_values: {},
- })
-
- for (const move of history) {
- tempChess.move(move)
- moves.push({
- board: tempChess.fen(),
- lastMove: [move.from, move.to] as [string, string],
- san: move.san,
- check: tempChess.inCheck(),
- maia_values: {},
- })
- }
-
- const tree = buildGameTree(moves, startingFen)
-
- return {
- id: id || `pgn-${Date.now()}`,
- blackPlayer: { name: headers.Black || 'Black', rating: undefined },
- whitePlayer: { name: headers.White || 'White', rating: undefined },
- moves,
- availableMoves: new Array(moves.length).fill({}),
- gameType: 'custom',
- termination: {
- result: headers.Result || '*',
- winner:
- headers.Result === '1-0'
- ? 'white'
- : headers.Result === '0-1'
- ? 'black'
- : 'none',
- condition: 'Normal',
- },
- maiaEvaluations: new Array(moves.length).fill({}),
- stockfishEvaluations: new Array(moves.length).fill(undefined),
- tree,
- type: 'custom-pgn' as const,
- pgn,
- } as AnalyzedGame
-}
-
-export const getAnalyzedCustomPGN = async (
- pgn: string,
- name?: string,
-): Promise => {
- const { saveCustomAnalysis } = await import('src/lib/customAnalysis')
-
- const stored = saveCustomAnalysis('pgn', pgn, name)
-
- return createAnalyzedGameFromPGN(pgn, stored.id)
-}
-
-const createAnalyzedGameFromFEN = async (
- fen: string,
- id?: string,
-): Promise => {
- const { Chess } = await import('chess.ts')
- const chess = new Chess()
-
- try {
- chess.load(fen)
- } catch (error) {
- throw new Error('Invalid FEN format')
- }
-
- const moves = [
- {
- board: fen,
- lastMove: undefined,
- san: undefined,
- check: chess.inCheck(),
- maia_values: {},
- },
- ]
-
- const tree = new GameTree(fen)
-
- return {
- id: id || `fen-${Date.now()}`,
- blackPlayer: { name: 'Black', rating: undefined },
- whitePlayer: { name: 'White', rating: undefined },
- moves,
- availableMoves: [{}],
- gameType: 'custom',
- termination: {
- result: '*',
- winner: 'none',
- condition: 'Normal',
- },
- maiaEvaluations: [{}],
- stockfishEvaluations: [undefined],
- tree,
- type: 'custom-fen' as const,
- } as AnalyzedGame
-}
-
-export const getAnalyzedCustomFEN = async (
- fen: string,
- name?: string,
-): Promise => {
- const { saveCustomAnalysis } = await import('src/lib/customAnalysis')
-
- const stored = saveCustomAnalysis('fen', fen, name)
-
- return createAnalyzedGameFromFEN(fen, stored.id)
-}
-
-export const getAnalyzedCustomGame = async (
- id: string,
-): Promise => {
- const { getCustomAnalysisById } = await import('src/lib/customAnalysis')
-
- const stored = getCustomAnalysisById(id)
- if (!stored) {
- throw new Error('Custom analysis not found')
- }
-
- if (stored.type === 'custom-pgn') {
- return createAnalyzedGameFromPGN(stored.data, stored.id)
- } else {
- return createAnalyzedGameFromFEN(stored.data, stored.id)
- }
-}
-
-export const getAnalyzedUserGame = async (
- id: string,
- game_type: 'play' | 'hand' | 'brain',
-) => {
- const res = await fetch(
- buildUrl(
- `analysis/user/analyze_user_maia_game/${id}?` +
- new URLSearchParams({
- game_type,
- }),
- ),
- {
- method: 'GET',
- headers: {
- 'Content-Type': 'text/plain',
- },
- },
- )
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- const data = await res.json()
-
- const termination = {
- ...data['termination'],
- condition: 'Normal',
- }
-
- const gameType = 'blitz'
- const blackPlayer = data['black_player'] as Player
- const whitePlayer = data['white_player'] as Player
-
- const maiaPattern = /maia_kdd_1\d00/
-
- if (blackPlayer.name && maiaPattern.test(blackPlayer.name)) {
- blackPlayer.name = blackPlayer.name.replace('maia_kdd_', 'Maia ')
- }
-
- if (whitePlayer.name && maiaPattern.test(whitePlayer.name)) {
- whitePlayer.name = whitePlayer.name.replace('maia_kdd_', 'Maia ')
- }
-
- const maiaEvals: { [model: string]: MoveMap[] } = {}
-
- const availableMoves: AvailableMoves[] = []
-
- for (const model of data['maia_versions']) {
- maiaEvals[model] = data['maia_evals'][model]
- }
-
- for (const position of data['move_maps']) {
- const moves: AvailableMoves = {}
- for (const move of position) {
- const fromTo = move.move.join('')
- const san = move['move_san']
- const { check, fen } = move
-
- moves[fromTo] = {
- board: fen,
- check,
- san,
- lastMove: move.move,
- }
- }
- availableMoves.push(moves)
- }
-
- const gameStates = data['game_states']
-
- const moves = gameStates.map((gameState: any) => {
- const {
- last_move: lastMove,
- fen,
- check,
- last_move_san: san,
- evaluations: maia_values,
- } = gameState
-
- return {
- board: fen,
- lastMove,
- san,
- check,
- maia_values,
- }
- })
-
- const maiaEvaluations = [] as { [rating: number]: MaiaEvaluation }[]
- const stockfishEvaluations: StockfishEvaluation[] = []
- const tree = buildGameTree(moves, moves[0].board)
-
- return {
- id,
- blackPlayer,
- whitePlayer,
- moves,
- availableMoves,
- gameType,
- termination,
- maiaEvaluations,
- stockfishEvaluations,
- tree,
- type: 'brain',
- } as AnalyzedGame
-}
-
-export interface EngineAnalysisPosition {
- ply: number
- fen: string
- maia?: { [rating: string]: MaiaEvaluation }
- stockfish?: {
- depth: number
- cp_vec: { [move: string]: number }
- }
-}
-
-export const storeEngineAnalysis = async (
- gameId: string,
- analysisData: EngineAnalysisPosition[],
-): Promise => {
- const res = await fetch(
- buildUrl(`analysis/store_engine_analysis/${gameId}`),
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(analysisData),
- },
- )
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- if (!res.ok) {
- throw new Error('Failed to store engine analysis')
- }
-}
-
-// Retrieve stored engine analysis from backend
-export const getEngineAnalysis = async (
- gameId: string,
-): Promise<{ positions: EngineAnalysisPosition[] } | null> => {
- const res = await fetch(buildUrl(`analysis/get_engine_analysis/${gameId}`))
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- if (res.status === 404) {
- // No stored analysis found
- return null
- }
-
- if (!res.ok) {
- throw new Error('Failed to retrieve engine analysis')
- }
-
- return res.json()
-}
diff --git a/src/api/analysis/index.ts b/src/api/analysis/index.ts
deleted file mode 100644
index 87dd896f..00000000
--- a/src/api/analysis/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './analysis'
diff --git a/src/api/auth/auth.ts b/src/api/auth.ts
similarity index 61%
rename from src/api/auth/auth.ts
rename to src/api/auth.ts
index 2fa21c99..6adbba08 100644
--- a/src/api/auth/auth.ts
+++ b/src/api/auth.ts
@@ -1,4 +1,4 @@
-import { buildUrl } from 'src/api'
+import { buildUrl } from './utils'
const parseAccountInfo = (data: { [x: string]: string }) => {
const clientId = data['client_id']
@@ -12,29 +12,22 @@ const parseAccountInfo = (data: { [x: string]: string }) => {
}
}
-export const getAccount = async () => {
+export const fetchAccount = async () => {
const res = await fetch(buildUrl('auth/account'))
const data = await res.json()
return parseAccountInfo(data)
}
-export const logoutAndGetAccount = async () => {
+export const logoutAndFetchAccount = async () => {
await fetch(buildUrl('auth/logout'))
- return getAccount()
+ return fetchAccount()
}
-export const getLeaderboard = async () => {
+export const fetchLeaderboard = async () => {
const res = await fetch(buildUrl('auth/leaderboard'))
const data = await res.json()
return data
}
-
-export const getGlobalStats = async () => {
- const res = await fetch(buildUrl('auth/global_stats'))
- const data = await res.json()
-
- return data
-}
diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts
deleted file mode 100644
index f140b2ec..00000000
--- a/src/api/auth/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './auth'
diff --git a/src/api/broadcasts.ts b/src/api/broadcasts.ts
new file mode 100644
index 00000000..7526d1e9
--- /dev/null
+++ b/src/api/broadcasts.ts
@@ -0,0 +1,469 @@
+import { Chess } from 'chess.ts'
+import {
+ Broadcast,
+ BroadcastGame,
+ PGNParseResult,
+ TopBroadcastsResponse,
+ TopBroadcastItem,
+} from 'src/types'
+
+const readStream = (processLine: (data: any) => void) => (response: any) => {
+ const stream = response.body.getReader()
+ const matcher = /\r?\n/
+ const decoder = new TextDecoder()
+ let buf = ''
+
+ const loop = () =>
+ stream.read().then(({ done, value }: { done: boolean; value: any }) => {
+ if (done) {
+ if (buf.length > 0) processLine(JSON.parse(buf))
+ } else {
+ const chunk = decoder.decode(value, {
+ stream: true,
+ })
+ buf += chunk
+
+ const parts = (buf || '').split(matcher)
+ buf = parts.pop() as string
+ for (const i of parts.filter((p) => p)) processLine(JSON.parse(i))
+
+ return loop()
+ }
+ })
+
+ return loop()
+}
+
+export const getLichessBroadcasts = async (): Promise => {
+ const response = await fetch('https://lichess.org/api/broadcast', {
+ headers: {
+ Accept: 'application/x-ndjson',
+ },
+ })
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+ }
+
+ if (!response.body) {
+ throw new Error('No response body')
+ }
+
+ const broadcasts: Broadcast[] = []
+
+ return new Promise((resolve, reject) => {
+ const onMessage = (message: any) => {
+ try {
+ broadcasts.push(message as Broadcast)
+ } catch (error) {
+ console.error('Error parsing broadcast message:', error)
+ }
+ }
+
+ const onComplete = () => {
+ resolve(broadcasts)
+ }
+
+ readStream(onMessage)(response).then(onComplete).catch(reject)
+ })
+}
+
+export const getLichessBroadcastById = async (
+ broadcastId: string,
+): Promise => {
+ try {
+ console.log('Fetching broadcast by ID:', broadcastId)
+ const response = await fetch(
+ `https://lichess.org/api/broadcast/${broadcastId}`,
+ {
+ headers: {
+ Accept: 'application/json',
+ },
+ },
+ )
+
+ if (!response.ok) {
+ console.error(`Failed to fetch broadcast: ${response.status}`)
+ return null
+ }
+
+ const data = await response.json()
+ console.log('Broadcast data received:', {
+ name: data.tour?.name,
+ rounds: data.rounds?.length,
+ roundNames: data.rounds?.map((r: any) => r.name),
+ })
+
+ // Validate that this looks like broadcast data
+ if (data.tour && data.rounds) {
+ return data as Broadcast
+ }
+
+ console.error('Invalid broadcast data structure')
+ return null
+ } catch (error) {
+ console.error('Error fetching broadcast by ID:', error)
+ return null
+ }
+}
+
+export const getLichessTopBroadcasts =
+ async (): Promise => {
+ const response = await fetch('https://lichess.org/api/broadcast/top', {
+ headers: {
+ Accept: 'application/json',
+ },
+ })
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+ }
+
+ return response.json()
+ }
+
+export const convertTopBroadcastToBroadcast = (
+ item: TopBroadcastItem,
+): Broadcast => {
+ return {
+ tour: item.tour,
+ rounds: [item.round],
+ defaultRoundId: item.round.id,
+ }
+}
+
+export const getBroadcastRoundPGN = async (
+ roundId: string,
+): Promise => {
+ const response = await fetch(
+ `https://lichess.org/api/broadcast/round/${roundId}.pgn`,
+ {
+ headers: {
+ Accept: 'application/x-chess-pgn',
+ },
+ },
+ )
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+ }
+
+ return await response.text()
+}
+
+export const streamBroadcastRound = async (
+ roundId: string,
+ onPGNUpdate: (pgn: string) => void,
+ onComplete: () => void,
+ abortSignal?: AbortSignal,
+) => {
+ const stream = fetch(
+ `https://lichess.org/api/stream/broadcast/round/${roundId}.pgn`,
+ {
+ signal: abortSignal,
+ headers: {
+ Accept: 'application/x-chess-pgn',
+ },
+ },
+ )
+
+ const onMessage = (data: string) => {
+ if (data.trim()) {
+ onPGNUpdate(data)
+ }
+ }
+
+ try {
+ const response = await stream
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+ }
+
+ if (!response.body) {
+ throw new Error('No response body')
+ }
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+
+ while (true) {
+ const { done, value } = await reader.read()
+
+ if (done) {
+ if (buffer.trim()) {
+ onMessage(buffer)
+ }
+ break
+ }
+
+ const chunk = decoder.decode(value, { stream: true })
+ buffer += chunk
+
+ // Split on double newlines to separate PGN games
+ const parts = buffer.split('\n\n\n')
+ buffer = parts.pop() || ''
+
+ for (const part of parts) {
+ if (part.trim()) {
+ onMessage(part)
+ }
+ }
+ }
+
+ onComplete()
+ } catch (error) {
+ if (abortSignal?.aborted) {
+ console.log('Broadcast stream aborted')
+ } else {
+ console.error('Broadcast stream error:', error)
+ throw error
+ }
+ }
+}
+
+export const parsePGNData = (pgnData: string): PGNParseResult => {
+ const games: BroadcastGame[] = []
+ const errors: string[] = []
+
+ try {
+ // Split the PGN data into individual games
+ const gameStrings = pgnData
+ .split(/\n\n\[Event/)
+ .filter((game) => game.trim())
+
+ for (let i = 0; i < gameStrings.length; i++) {
+ let gameString = gameStrings[i]
+
+ // Add back the [Event header if it was removed by split
+ if (i > 0 && !gameString.startsWith('[Event')) {
+ gameString = '[Event' + gameString
+ }
+
+ try {
+ const game = parseSinglePGN(gameString)
+ if (game) {
+ games.push(game)
+ }
+ } catch (error) {
+ errors.push(`Error parsing game ${i + 1}: ${error}`)
+ }
+ }
+ } catch (error) {
+ errors.push(`Error splitting PGN data: ${error}`)
+ }
+
+ return { games, errors }
+}
+
+const parseSinglePGN = (pgnString: string): BroadcastGame | null => {
+ const lines = pgnString.trim().split('\n')
+ const headers: Record = {}
+ let movesSection = ''
+ let inMoves = false
+
+ // Parse headers and moves
+ for (const line of lines) {
+ const trimmedLine = line.trim()
+
+ if (trimmedLine.startsWith('[') && trimmedLine.endsWith(']')) {
+ // Parse header
+ const match = trimmedLine.match(/^\[(\w+)\s+"([^"]*)"\]$/)
+ if (match) {
+ headers[match[1]] = match[2]
+ }
+ } else if (trimmedLine && !inMoves) {
+ inMoves = true
+ movesSection = trimmedLine
+ } else if (inMoves && trimmedLine) {
+ movesSection += ' ' + trimmedLine
+ }
+ }
+
+ // Extract essential data
+ const white = headers.White || 'Unknown'
+ const black = headers.Black || 'Unknown'
+ const result = headers.Result || '*'
+ const event = headers.Event || ''
+ const site = headers.Site || ''
+ const date = headers.Date || headers.UTCDate || ''
+ const round = headers.Round || ''
+
+ // Parse moves and clock information from full PGN
+ console.log(`Parsing PGN for ${white} vs ${black}`)
+ const parseResult = parseMovesAndClocksFromPGN(pgnString)
+ const moves = parseResult.moves
+ const { whiteClock, blackClock } = parseResult
+ const fen = extractFENFromMoves()
+
+ // Debug clock parsing
+ if (whiteClock || blackClock) {
+ console.log(`Clock data for ${white} vs ${black}:`, {
+ whiteClock,
+ blackClock,
+ movesSection: movesSection.substring(0, 200) + '...',
+ })
+ }
+
+ const game: BroadcastGame = {
+ id: generateGameId(white, black, event, site),
+ white,
+ black,
+ result,
+ moves,
+ pgn: pgnString,
+ fen: fen || 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
+ event,
+ site,
+ date,
+ round,
+ eco: headers.ECO,
+ opening: headers.Opening,
+ whiteElo: headers.WhiteElo ? parseInt(headers.WhiteElo) : undefined,
+ blackElo: headers.BlackElo ? parseInt(headers.BlackElo) : undefined,
+ timeControl: headers.TimeControl,
+ termination: headers.Termination,
+ annotator: headers.Annotator,
+ studyName: headers.StudyName,
+ chapterName: headers.ChapterName,
+ utcDate: headers.UTCDate,
+ utcTime: headers.UTCTime,
+ whiteClock,
+ blackClock,
+ }
+
+ // Note: Last move extraction would need proper move parsing to convert SAN to UCI
+ // For now, we'll leave it undefined and handle in the controller
+
+ return game
+}
+
+const parseMovesAndClocksFromPGN = (
+ pgnString: string,
+): {
+ moves: string[]
+ whiteClock?: {
+ timeInSeconds: number
+ isActive: boolean
+ lastUpdateTime: number
+ }
+ blackClock?: {
+ timeInSeconds: number
+ isActive: boolean
+ lastUpdateTime: number
+ }
+} => {
+ const moves: string[] = []
+ let whiteClock:
+ | { timeInSeconds: number; isActive: boolean; lastUpdateTime: number }
+ | undefined
+ let blackClock:
+ | { timeInSeconds: number; isActive: boolean; lastUpdateTime: number }
+ | undefined
+
+ try {
+ // Use chess.js to parse the full PGN
+ const chess = new Chess()
+ const success = chess.loadPgn(pgnString)
+
+ if (!success) {
+ console.warn(
+ 'Failed to parse PGN with chess.js, falling back to manual parsing',
+ )
+ return { moves }
+ }
+
+ // Get all moves from the game history
+ const history = chess.history({ verbose: true })
+ for (const move of history) {
+ moves.push(move.san)
+ }
+
+ // Get comments which contain clock information
+ const comments = chess.getComments()
+ let lastWhiteClock: any = null
+ let lastBlackClock: any = null
+
+ for (const commentData of comments) {
+ const comment = commentData.comment
+
+ // Extract clock from comment using regex
+ const clockMatch = comment.match(/\[%clk\s+(\d+):(\d+)(?::(\d+))?\]/)
+ if (clockMatch) {
+ const hours = clockMatch[3] ? parseInt(clockMatch[1]) : 0
+ const minutes = clockMatch[3]
+ ? parseInt(clockMatch[2])
+ : parseInt(clockMatch[1])
+ const seconds = clockMatch[3]
+ ? parseInt(clockMatch[3])
+ : parseInt(clockMatch[2])
+
+ const timeInSeconds = hours * 3600 + minutes * 60 + seconds
+ const clockData = {
+ timeInSeconds,
+ isActive: false,
+ lastUpdateTime: Date.now(),
+ }
+
+ // Determine if this is white or black's move based on the FEN
+ const chess_temp = new Chess(commentData.fen)
+ const isWhiteToMove = chess_temp.turn() === 'b' // After white's move, it's black's turn
+
+ if (isWhiteToMove) {
+ lastWhiteClock = clockData
+ } else {
+ lastBlackClock = clockData
+ }
+
+ console.log(
+ `Found clock for ${isWhiteToMove ? 'white' : 'black'}: ${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')} = ${timeInSeconds}s`,
+ )
+ }
+ }
+
+ whiteClock = lastWhiteClock
+ blackClock = lastBlackClock
+
+ // Determine which clock is active based on current turn
+ if (moves.length > 0) {
+ const finalPosition = new Chess()
+ finalPosition.loadPgn(pgnString)
+ const isCurrentlyWhiteTurn = finalPosition.turn() === 'w'
+
+ if (whiteClock) {
+ whiteClock.isActive = isCurrentlyWhiteTurn
+ }
+ if (blackClock) {
+ blackClock.isActive = !isCurrentlyWhiteTurn
+ }
+ }
+ } catch (error) {
+ console.warn('Error parsing PGN with chess.js:', error)
+ }
+
+ return { moves, whiteClock, blackClock }
+}
+
+const extractFENFromMoves = (): string | null => {
+ // This would require a full chess engine to calculate the FEN from moves
+ // For now, return null and handle in the controller with chess.js
+ return null
+}
+
+const generateGameId = (
+ white: string,
+ black: string,
+ event: string,
+ site: string,
+): string => {
+ const baseString = `${white}-${black}-${event}-${site}`
+ // Use a simple hash instead of deprecated btoa for better compatibility
+ let hash = 0
+ for (let i = 0; i < baseString.length; i++) {
+ const char = baseString.charCodeAt(i)
+ hash = (hash << 5) - hash + char
+ hash = hash & hash // Convert to 32bit integer
+ }
+ return Math.abs(hash).toString(36).substring(0, 12)
+}
diff --git a/src/api/home/activeUsers.ts b/src/api/home.ts
similarity index 66%
rename from src/api/home/activeUsers.ts
rename to src/api/home.ts
index 5d2f963b..0f359c60 100644
--- a/src/api/home/activeUsers.ts
+++ b/src/api/home.ts
@@ -1,7 +1,5 @@
-/**
- * Get the count of active users in the last 30 minutes
- * Calls our secure server-side API endpoint that handles PostHog integration
- */
+import { buildUrl } from './utils'
+
export const getActiveUserCount = async (): Promise => {
try {
const response = await fetch('/api/active-users')
@@ -17,3 +15,10 @@ export const getActiveUserCount = async (): Promise => {
return 0
}
+
+export const getGlobalStats = async () => {
+ const res = await fetch(buildUrl('auth/global_stats'))
+ const data = await res.json()
+
+ return data
+}
diff --git a/src/api/home/home.ts b/src/api/home/home.ts
deleted file mode 100644
index 16794b18..00000000
--- a/src/api/home/home.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { buildUrl } from 'src/api'
-
-const getPlayerStats = async () => {
- const res = await fetch(buildUrl('/auth/get_player_stats'))
- const data = await res.json()
- return {
- regularRating: data.play_elo as number,
- handRating: data.hand_elo as number,
- brainRating: data.brain_elo as number,
- trainRating: data.puzzles_elo as number,
- botNotRating: data.turing_elo as number,
- }
-}
-
-export { getPlayerStats }
diff --git a/src/api/home/index.ts b/src/api/home/index.ts
deleted file mode 100644
index 66a56239..00000000
--- a/src/api/home/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './home'
-export * from './activeUsers'
diff --git a/src/api/index.ts b/src/api/index.ts
index 7aa504f5..28374a70 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -1,9 +1,11 @@
-export * from './utils'
export * from './analysis'
export * from './train'
export * from './auth'
export * from './turing'
export * from './play'
export * from './profile'
-export * from './opening'
-export { getActiveUserCount } from './home'
+export * from './openings'
+export * from './lichess'
+export * from './home'
+export * from './utils'
+export * from './broadcasts'
diff --git a/src/api/lichess.ts b/src/api/lichess.ts
new file mode 100644
index 00000000..211e7eca
--- /dev/null
+++ b/src/api/lichess.ts
@@ -0,0 +1,82 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { readLichessStream } from 'src/lib'
+import { StreamedGame, StreamedMove } from 'src/types'
+
+export const fetchLichessTVGame = async () => {
+ const res = await fetch('https://lichess.org/api/tv/channels')
+ if (!res.ok) {
+ throw new Error('Failed to fetch Lichess TV data')
+ }
+ const data = await res.json()
+
+ // Return the best rapid game (highest rated players)
+ const bestChannel = data.rapid
+ if (!bestChannel?.gameId) {
+ throw new Error('No TV game available')
+ }
+
+ return {
+ gameId: bestChannel.gameId,
+ white: bestChannel.user1,
+ black: bestChannel.user2,
+ }
+}
+
+export const fetchLichessGameInfo = async (gameId: string) => {
+ const res = await fetch(`https://lichess.org/api/game/${gameId}`)
+ if (!res.ok) {
+ throw new Error(`Failed to fetch game info for ${gameId}`)
+ }
+ return res.json()
+}
+
+export const streamLichessGameMoves = async (
+ gameId: string,
+ onGameInfo: (data: StreamedGame) => void,
+ onMove: (data: StreamedMove) => void,
+ onComplete: () => void,
+ abortSignal?: AbortSignal,
+) => {
+ const stream = fetch(`https://lichess.org/api/stream/game/${gameId}`, {
+ signal: abortSignal,
+ headers: {
+ Accept: 'application/x-ndjson',
+ },
+ })
+
+ const onMessage = (message: any) => {
+ if (message.id) {
+ onGameInfo(message as StreamedGame)
+ } else if (message.uci || message.lm) {
+ onMove({
+ fen: message.fen,
+ uci: message.uci || message.lm,
+ wc: message.wc,
+ bc: message.bc,
+ })
+ } else {
+ console.log('Unknown message format:', message)
+ }
+ }
+
+ try {
+ const response = await stream
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+ }
+
+ if (!response.body) {
+ throw new Error('No response body')
+ }
+
+ await readLichessStream(onMessage)(response).then(onComplete)
+ } catch (error) {
+ if (abortSignal?.aborted) {
+ console.log('Stream aborted')
+ } else {
+ console.error('Stream error:', error)
+ throw error
+ }
+ }
+}
diff --git a/src/api/opening/index.ts b/src/api/opening/index.ts
deleted file mode 100644
index 955f883d..00000000
--- a/src/api/opening/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './opening'
diff --git a/src/api/opening/opening.ts b/src/api/opening/opening.ts
deleted file mode 100644
index 8ac24884..00000000
--- a/src/api/opening/opening.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { buildUrl } from '../utils'
-
-// API Types for opening drill logging
-export interface OpeningDrillSelection {
- opening_fen: string
- side_played: string
-}
-
-export interface SelectOpeningDrillsRequest {
- openings: OpeningDrillSelection[]
- opponent: string
- num_moves: number
- num_drills: number
-}
-
-export interface SelectOpeningDrillsResponse {
- session_id: string
-}
-
-export interface SubmitOpeningDrillRequest {
- session_id: string
- opening_fen: string
- side_played: string
- moves_played_uci: string[]
-}
-
-// API function to log opening drill selections and start a session
-export const selectOpeningDrills = async (
- request: SelectOpeningDrillsRequest,
-): Promise => {
- const res = await fetch(buildUrl('opening/select_opening_drills'), {
- method: 'POST',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(request),
- })
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- if (!res.ok) {
- throw new Error(`Failed to select opening drills: ${res.statusText}`)
- }
-
- const data = await res.json()
- return data as SelectOpeningDrillsResponse
-}
-
-// API function to submit a completed opening drill
-export const submitOpeningDrill = async (
- request: SubmitOpeningDrillRequest,
-): Promise => {
- const res = await fetch(buildUrl('opening/record_opening_drill'), {
- method: 'POST',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(request),
- })
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- if (!res.ok) {
- throw new Error(`Failed to submit opening drill: ${res.statusText}`)
- }
-}
diff --git a/src/api/openings.ts b/src/api/openings.ts
new file mode 100644
index 00000000..cac80bd5
--- /dev/null
+++ b/src/api/openings.ts
@@ -0,0 +1,30 @@
+import { buildUrl } from './utils'
+
+export interface LogOpeningDrillRequest {
+ opening_fen: string
+ side_played: string
+ opponent: string
+ num_moves: number
+ moves_played_uci: string[]
+}
+
+export const logOpeningDrill = async (
+ request: LogOpeningDrillRequest,
+): Promise => {
+ const res = await fetch(buildUrl('opening/log_opening_drill'), {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(request),
+ })
+
+ if (res.status === 401) {
+ throw new Error('Unauthorized')
+ }
+
+ if (!res.ok) {
+ throw new Error(`Failed to log opening drill: ${res.statusText}`)
+ }
+}
diff --git a/src/api/play/play.ts b/src/api/play.ts
similarity index 95%
rename from src/api/play/play.ts
rename to src/api/play.ts
index 7c6d264c..1a56cfeb 100644
--- a/src/api/play/play.ts
+++ b/src/api/play.ts
@@ -1,4 +1,4 @@
-import { buildUrl } from '../utils'
+import { buildUrl } from './utils'
import { Color, TimeControl } from 'src/types'
export const startGame = async (
@@ -48,7 +48,7 @@ export const startGame = async (
}
}
-export const getGameMove = async (
+export const fetchGameMove = async (
moves: string[],
maiaVersion = 'maia_kdd_1900',
fen: string | null = null,
@@ -106,7 +106,7 @@ export const getGameMove = async (
return res.json()
}
-export const getBookMoves = async (fen: string) => {
+export const fetchOpeningBookMoves = async (fen: string) => {
const res = await fetch(buildUrl(`play/get_book_moves?fen=${fen}`), {
method: 'POST',
headers: {
@@ -132,7 +132,7 @@ export const getBookMoves = async (fen: string) => {
return res.json()
}
-export const submitGameMove = async (
+export const logGameMove = async (
gameId: string,
moves: string[],
moveTimes: number[],
@@ -164,7 +164,7 @@ export const submitGameMove = async (
return res.json()
}
-export const getPlayPlayerStats = async () => {
+export const fetchPlayPlayerStats = async () => {
const res = await fetch(buildUrl('play/get_player_stats'))
const data = await res.json()
return {
diff --git a/src/api/play/index.ts b/src/api/play/index.ts
deleted file mode 100644
index 1cf1ab44..00000000
--- a/src/api/play/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './play'
diff --git a/src/api/profile/profile.ts b/src/api/profile.ts
similarity index 93%
rename from src/api/profile/profile.ts
rename to src/api/profile.ts
index 407e866c..e72f4309 100644
--- a/src/api/profile/profile.ts
+++ b/src/api/profile.ts
@@ -1,7 +1,7 @@
-import { buildUrl } from '../utils'
+import { buildUrl } from './utils'
import { PlayerStats } from 'src/types'
-export const getPlayerStats = async (name?: string): Promise => {
+export const fetchPlayerStats = async (name?: string): Promise => {
const res = await fetch(
buildUrl(`auth/get_player_stats${name ? `/${name}` : ''}`),
)
diff --git a/src/api/profile/index.ts b/src/api/profile/index.ts
deleted file mode 100644
index 060535fd..00000000
--- a/src/api/profile/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './profile'
diff --git a/src/api/train/train.ts b/src/api/train.ts
similarity index 86%
rename from src/api/train/train.ts
rename to src/api/train.ts
index 0dcf778d..9a29f8e5 100644
--- a/src/api/train/train.ts
+++ b/src/api/train.ts
@@ -1,10 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
-import { Chess } from 'chess.ts'
-import { MoveMap, GameTree } from 'src/types'
-import { AvailableMoves, TrainingGame } from 'src/types/training'
-import { buildUrl } from '../utils'
+import { buildUrl } from './utils'
+import { MoveValueMapping, GameTree } from 'src/types'
+import { AvailableMoves, PuzzleGame } from 'src/types/puzzle'
-export const getTrainingGame = async () => {
+export const fetchPuzzle = async () => {
const res = await fetch(buildUrl('puzzle/new_puzzle'))
const data = await res.json()
const id =
@@ -62,19 +61,16 @@ export const getTrainingGame = async () => {
for (let i = 1; i < moves.length; i++) {
const move = moves[i]
if (move.uci && move.san) {
- currentNode = gameTree.addMainMove(
- currentNode,
- move.board,
- move.uci,
- move.san,
- )
+ currentNode = gameTree
+ .getLastMainlineNode()
+ .addChild(move.board, move.uci, move.san, true)
}
}
const moveMap = data['target_move_map']
- const stockfishEvaluation: MoveMap = {}
- const maiaEvaluation: MoveMap = {}
+ const stockfishEvaluation: MoveValueMapping = {}
+ const maiaEvaluation: MoveValueMapping = {}
const availableMoves: AvailableMoves = {}
moveMap.forEach(
@@ -112,7 +108,7 @@ export const getTrainingGame = async () => {
termination,
availableMoves,
targetIndex: data['target_move_index'],
- } as any as TrainingGame
+ } as any as PuzzleGame
}
export const logPuzzleGuesses = async (
@@ -150,7 +146,7 @@ export const logPuzzleGuesses = async (
return res.json()
}
-export const getTrainingPlayerStats = async () => {
+export const fetchTrainingPlayerStats = async () => {
const res = await fetch(buildUrl('puzzle/get_player_stats'))
const data = await res.json()
return {
diff --git a/src/api/train/index.ts b/src/api/train/index.ts
deleted file mode 100644
index 2bf07805..00000000
--- a/src/api/train/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './train'
diff --git a/src/api/turing/turing.ts b/src/api/turing.ts
similarity index 91%
rename from src/api/turing/turing.ts
rename to src/api/turing.ts
index 8b4316b4..e3162b32 100644
--- a/src/api/turing/turing.ts
+++ b/src/api/turing.ts
@@ -1,7 +1,7 @@
+import { buildUrl } from './utils'
import { Color, TuringGame, TuringSubmissionResult, GameTree } from 'src/types'
-import { buildUrl } from 'src/api'
-export const getTuringGame = async () => {
+export const fetchTuringGame = async () => {
const res = await fetch(buildUrl('turing/new_game'))
if (res.status === 401) {
@@ -46,12 +46,9 @@ export const getTuringGame = async () => {
for (let i = 1; i < moves.length; i++) {
const move = moves[i]
if (move.uci && move.san) {
- currentNode = gameTree.addMainMove(
- currentNode,
- move.board,
- move.uci,
- move.san,
- )
+ currentNode = gameTree
+ .getLastMainlineNode()
+ .addChild(move.board, move.uci, move.san, true)
}
}
@@ -114,7 +111,7 @@ export const submitTuringGuess = async (
} as TuringSubmissionResult
}
-export const getTuringPlayerStats = async () => {
+export const fetchTuringPlayerStats = async () => {
const res = await fetch(buildUrl('turing/get_player_stats'))
const data = await res.json()
return {
diff --git a/src/api/turing/index.ts b/src/api/turing/index.ts
deleted file mode 100644
index 230f2233..00000000
--- a/src/api/turing/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './turing'
diff --git a/src/components/Analysis/AnalysisConfigModal.tsx b/src/components/Analysis/AnalysisConfigModal.tsx
index bdb05d94..b40aa62f 100644
--- a/src/components/Analysis/AnalysisConfigModal.tsx
+++ b/src/components/Analysis/AnalysisConfigModal.tsx
@@ -55,7 +55,7 @@ export const AnalysisConfigModal: React.FC = ({
return (
= ({
}}
>
e.stopPropagation()}
>
-
-
+
+
network_intelligence
-
Analyze Entire Game
+
+ Analyze Entire Game
+
-
+
Choose the Stockfish analysis depth for all positions in the game:
@@ -89,10 +91,10 @@ export const AnalysisConfigModal: React.FC
= ({
{depthOptions.map((option) => (
= ({
value={option.value}
checked={selectedDepth === option.value}
onChange={(e) => setSelectedDepth(Number(e.target.value))}
- className="h-4 w-4 text-human-4"
+ className="h-4 w-4 border-white/30 text-human-4 focus:ring-human-4"
/>
- {option.label}
-
+
+ {option.label}
+
+
{option.description}
@@ -116,29 +120,29 @@ export const AnalysisConfigModal: React.FC = ({
))}
-
-
+
+
info
-
+
Higher depths provide more accurate analysis but take longer to
- complete. You can cancel the analysis at any time. Currently,
- analysis only persists until you close the tab, but we are working
- on a persistent analysis feature!
+ complete. You can cancel the analysis at any time. Analysis will
+ persist even after you close the tab,
-
+
+ close
Cancel
play_arrow
diff --git a/src/components/Analysis/AnalysisGameList.tsx b/src/components/Analysis/AnalysisGameList.tsx
index 5c9007ba..14d2d68c 100644
--- a/src/components/Analysis/AnalysisGameList.tsx
+++ b/src/components/Analysis/AnalysisGameList.tsx
@@ -11,16 +11,14 @@ import { motion } from 'framer-motion'
import { Tournament } from 'src/components'
import { FavoriteModal } from 'src/components/Common/FavoriteModal'
import { AnalysisListContext } from 'src/contexts'
-import { getAnalysisGameList } from 'src/api'
-import { getCustomAnalysesAsWebGames } from 'src/lib/customAnalysis'
+import { fetchMaiaGameList, deleteCustomGame } from 'src/api'
import {
getFavoritesAsWebGames,
addFavoriteGame,
removeFavoriteGame,
updateFavoriteName,
- isFavoriteGame,
} from 'src/lib/favorites'
-import { AnalysisWebGame } from 'src/types'
+import { MaiaGameListEntry } from 'src/types'
import { useRouter } from 'next/router'
interface GameData {
@@ -28,87 +26,87 @@ interface GameData {
maia_name: string
result: string
player_color: 'white' | 'black'
+ is_favorited?: boolean
+ custom_name?: string
+}
+
+type CachedGameEntry = MaiaGameListEntry & {
+ is_favorited?: boolean
+ custom_name?: string
}
interface AnalysisGameListProps {
currentId: string[] | null
- loadNewTournamentGame: (
+ loadNewWorldChampionshipGame: (
newId: string[],
setCurrentMove?: Dispatch>,
) => Promise
- loadNewLichessGames: (
+ loadNewLichessGame: (
id: string,
pgn: string,
setCurrentMove?: Dispatch>,
) => Promise
- loadNewUserGames: (
+ loadNewMaiaGame: (
id: string,
type: 'play' | 'hand' | 'brain',
setCurrentMove?: Dispatch>,
) => Promise
- loadNewCustomGame: (
- id: string,
- setCurrentMove?: Dispatch>,
- ) => Promise
onCustomAnalysis?: () => void
onGameSelected?: () => void // Called when a game is selected (for mobile popup closing)
refreshTrigger?: number // Used to trigger refresh when custom analysis is added
+ embedded?: boolean // Render without outer card container
}
export const AnalysisGameList: React.FC = ({
currentId,
- loadNewTournamentGame,
- loadNewLichessGames,
- loadNewUserGames,
- loadNewCustomGame,
onCustomAnalysis,
onGameSelected,
refreshTrigger,
+ loadNewWorldChampionshipGame,
+ embedded = false,
}) => {
const router = useRouter()
- const {
- analysisPlayList,
- analysisHandList,
- analysisBrainList,
- analysisLichessList,
- analysisTournamentList,
- } = useContext(AnalysisListContext)
+ const { analysisLichessList, analysisTournamentList } =
+ useContext(AnalysisListContext)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [loading, setLoading] = useState(false)
const [gamesByPage, setGamesByPage] = useState<{
- [gameType: string]: { [page: number]: AnalysisWebGame[] }
+ [gameType: string]: { [page: number]: CachedGameEntry[] }
}>({
play: {},
hand: {},
brain: {},
+ favorites: {},
+ custom: {},
})
- const [customAnalyses, setCustomAnalyses] = useState(() => {
- if (typeof window !== 'undefined') {
- return getCustomAnalysesAsWebGames()
- }
- return []
- })
- const [favoriteGames, setFavoriteGames] = useState(() => {
- if (typeof window !== 'undefined') {
- return getFavoritesAsWebGames()
- }
- return []
- })
+ const [favoritedGameIds, setFavoritedGameIds] = useState>(
+ new Set(),
+ )
+ const [customNameOverrides, setCustomNameOverrides] = useState<
+ Record
+ >({})
+ const [favoritesInitialized, setFavoritesInitialized] = useState(false)
const [hbSubsection, setHbSubsection] = useState<'hand' | 'brain'>('hand')
- // Modal state for favoriting
- const [favoriteModal, setFavoriteModal] = useState<{
+ const [editModal, setEditModal] = useState<{
isOpen: boolean
- game: AnalysisWebGame | null
+ game: CachedGameEntry | null
}>({ isOpen: false, game: null })
useEffect(() => {
- setCustomAnalyses(getCustomAnalysesAsWebGames())
- setFavoriteGames(getFavoritesAsWebGames())
+ getFavoritesAsWebGames()
+ .then((favorites) => {
+ setFavoritedGameIds(new Set(favorites.map((f) => f.id)))
+ setFavoritesInitialized(true)
+ })
+ .catch(() => {
+ setFavoritedGameIds(new Set())
+ setFavoritesInitialized(true)
+ })
}, [refreshTrigger])
useEffect(() => {
@@ -123,8 +121,10 @@ export const AnalysisGameList: React.FC = ({
play: {},
hand: {},
brain: {},
+ custom: {},
lichess: {},
tournament: {},
+ favorites: {},
})
const [totalPagesCache, setTotalPagesCache] = useState<{
@@ -137,8 +137,10 @@ export const AnalysisGameList: React.FC = ({
play: 1,
hand: 1,
brain: 1,
+ custom: 1,
lichess: 1,
tournament: 1,
+ favorites: 1,
})
const listKeys = useMemo(() => {
@@ -185,13 +187,20 @@ export const AnalysisGameList: React.FC = ({
setLoadingIndex(null)
}, [selected])
+ useEffect(() => {
+ if (selected === 'custom') {
+ setFetchedCache((prev) => ({
+ ...prev,
+ custom: {},
+ }))
+ }
+ }, [refreshTrigger, selected])
+
useEffect(() => {
if (
selected !== 'tournament' &&
selected !== 'lichess' &&
- selected !== 'custom' &&
- selected !== 'hb' &&
- selected !== 'favorites'
+ selected !== 'hb'
) {
const isAlreadyFetched = fetchedCache[selected]?.[currentPage]
@@ -203,34 +212,66 @@ export const AnalysisGameList: React.FC = ({
[selected]: { ...prev[selected], [currentPage]: true },
}))
- getAnalysisGameList(selected, currentPage)
+ fetchMaiaGameList(selected, currentPage)
.then((data) => {
- const parse = (
- game: {
- game_id: string
- maia_name: string
- result: string
- player_color: 'white' | 'black'
- },
- type: string,
- ) => {
- const raw = game.maia_name.replace('_kdd_', ' ')
- const maia = raw.charAt(0).toUpperCase() + raw.slice(1)
+ console.log(data)
+ let parsedGames: CachedGameEntry[] = []
- return {
- id: game.game_id,
- label:
- game.player_color === 'white'
- ? `You vs. ${maia}`
- : `${maia} vs. You`,
- result: game.result,
- type,
+ if (selected === 'favorites') {
+ parsedGames = data.games.map((game: any) => ({
+ id: game.game_id || game.id,
+ type: game.game_type || game.type,
+ label: game.custom_name || game.label || 'Untitled',
+ result: game.result || '*',
+ pgn: game.pgn,
+ is_favorited: true, // All games in favorites are favorited
+ custom_name: game.custom_name,
+ }))
+ } else {
+ if (selected === 'custom') {
+ parsedGames = data.games.map((game: any) => ({
+ id: game.game_id || game.id,
+ type: 'custom',
+ label: game.custom_name || 'Custom Game',
+ result: game.result || '*',
+ is_favorited: game.is_favorited,
+ custom_name: game.custom_name,
+ }))
+ } else {
+ const parse = (
+ game: {
+ game_id: string
+ maia_name: string
+ result: string
+ player_color: 'white' | 'black'
+ is_favorited?: boolean
+ custom_name?: string
+ },
+ type: string,
+ ) => {
+ const raw = game.maia_name.replace('_kdd_', ' ')
+ const maia = raw.charAt(0).toUpperCase() + raw.slice(1)
+
+ const defaultLabel =
+ game.player_color === 'white'
+ ? `You vs. ${maia}`
+ : `${maia} vs. You`
+
+ return {
+ id: game.game_id,
+ label: game.custom_name || defaultLabel,
+ result: game.result,
+ type,
+ is_favorited: game.is_favorited || false,
+ custom_name: game.custom_name,
+ }
+ }
+
+ parsedGames = data.games.map((game: GameData) =>
+ parse(game, selected),
+ )
}
}
-
- const parsedGames = data.games.map((game: GameData) =>
- parse(game, selected),
- )
const calculatedTotalPages =
data.total_pages || Math.ceil(data.total_games / 25)
@@ -247,6 +288,15 @@ export const AnalysisGameList: React.FC = ({
},
}))
+ const favoritedIds = new Set(
+ parsedGames
+ .filter((game: any) => game.is_favorited)
+ .map((game: any) => game.id as string),
+ )
+ setFavoritedGameIds(
+ (prev) => new Set([...prev, ...favoritedIds]),
+ )
+
setLoading(false)
})
.catch(() => {
@@ -261,7 +311,6 @@ export const AnalysisGameList: React.FC = ({
}
}, [selected, currentPage, fetchedCache])
- // Separate useEffect for H&B subsections
useEffect(() => {
if (selected === 'hb') {
const gameType = hbSubsection === 'hand' ? 'hand' : 'brain'
@@ -275,7 +324,7 @@ export const AnalysisGameList: React.FC = ({
[gameType]: { ...prev[gameType], [currentPage]: true },
}))
- getAnalysisGameList(gameType, currentPage)
+ fetchMaiaGameList(gameType, currentPage)
.then((data) => {
const parse = (
game: {
@@ -283,20 +332,26 @@ export const AnalysisGameList: React.FC = ({
maia_name: string
result: string
player_color: 'white' | 'black'
+ is_favorited?: boolean
+ custom_name?: string
},
type: string,
) => {
const raw = game.maia_name.replace('_kdd_', ' ')
const maia = raw.charAt(0).toUpperCase() + raw.slice(1)
+ const defaultLabel =
+ game.player_color === 'white'
+ ? `You vs. ${maia}`
+ : `${maia} vs. You`
+
return {
id: game.game_id,
- label:
- game.player_color === 'white'
- ? `You vs. ${maia}`
- : `${maia} vs. You`,
+ label: game.custom_name || defaultLabel,
result: game.result,
type,
+ is_favorited: game.is_favorited || false,
+ custom_name: game.custom_name,
}
}
@@ -319,6 +374,15 @@ export const AnalysisGameList: React.FC = ({
},
}))
+ const favoritedIds = new Set(
+ parsedGames
+ .filter((game: any) => game.is_favorited)
+ .map((game: any) => game.id as string),
+ )
+ setFavoritedGameIds(
+ (prev) => new Set([...prev, ...favoritedIds]),
+ )
+
setLoading(false)
})
.catch(() => {
@@ -345,13 +409,12 @@ export const AnalysisGameList: React.FC = ({
} else if (totalPagesCache[selected]) {
setTotalPages(totalPagesCache[selected])
setCurrentPage(currentPagePerTab[selected] || 1)
- } else if (
- selected === 'lichess' ||
- selected === 'tournament' ||
- selected === 'custom'
- ) {
+ } else if (selected === 'lichess' || selected === 'tournament') {
setTotalPages(1)
setCurrentPage(1)
+ } else if (selected === 'custom') {
+ setTotalPages(totalPagesCache['custom'] || 1)
+ setCurrentPage(currentPagePerTab['custom'] || 1)
} else {
setTotalPages(1)
setCurrentPage(currentPagePerTab[selected] || 1)
@@ -382,36 +445,326 @@ export const AnalysisGameList: React.FC = ({
setSelected(newTab)
}
- const handleFavoriteGame = (game: AnalysisWebGame) => {
- setFavoriteModal({ isOpen: true, game })
+ const getDisplayName = (game: CachedGameEntry) => {
+ return customNameOverrides[game.id] ?? game.custom_name ?? game.label
+ }
+
+ const openEditModal = (game: CachedGameEntry) => {
+ const displayName = getDisplayName(game)
+ setEditModal({
+ isOpen: true,
+ game: {
+ ...game,
+ custom_name: displayName,
+ label: displayName,
+ },
+ })
+ }
+
+ const handleToggleFavorite = async (game: CachedGameEntry) => {
+ const isCurrentlyFavorited = favoritedGameIds.has(game.id)
+ const effectiveName = getDisplayName(game)
+
+ setFavoritedGameIds((prev) => {
+ const next = new Set(prev)
+ if (isCurrentlyFavorited) {
+ next.delete(game.id)
+ } else {
+ next.add(game.id)
+ }
+ return next
+ })
+
+ try {
+ if (isCurrentlyFavorited) {
+ await removeFavoriteGame(game.id, game.type)
+ } else {
+ await addFavoriteGame(
+ {
+ ...game,
+ label: effectiveName,
+ },
+ effectiveName,
+ )
+ }
+ } catch (error) {
+ console.error('Failed to toggle favourite', error)
+ }
+
+ const reconcileFavoriteIds = () => {
+ setFavoritedGameIds((prev) => {
+ const next = new Set(prev)
+ if (isCurrentlyFavorited) {
+ next.delete(game.id)
+ } else {
+ next.add(game.id)
+ }
+ return next
+ })
+ }
+
+ try {
+ const updatedFavorites = await getFavoritesAsWebGames()
+ const favoriteIds = new Set(updatedFavorites.map((f) => f.id))
+
+ if (isCurrentlyFavorited) {
+ favoriteIds.delete(game.id)
+ } else {
+ favoriteIds.add(game.id)
+ }
+
+ setFavoritedGameIds(favoriteIds)
+ setFavoritesInitialized(true)
+ } catch (error) {
+ console.error('Failed to refresh favourites', error)
+ reconcileFavoriteIds()
+ setFavoritesInitialized(true)
+ }
+
+ const currentSectionKey =
+ selected === 'hb'
+ ? hbSubsection === 'hand'
+ ? 'hand'
+ : 'brain'
+ : selected
+
+ if (
+ currentSectionKey === 'play' ||
+ currentSectionKey === 'hand' ||
+ currentSectionKey === 'brain' ||
+ currentSectionKey === 'custom'
+ ) {
+ setGamesByPage((prev) => {
+ const sectionPages = prev[currentSectionKey]
+ if (!sectionPages) return prev
+
+ const updatedSectionPages: { [page: number]: CachedGameEntry[] } = {}
+ let sectionMutated = false
+
+ Object.entries(sectionPages).forEach(([pageKey, gameList]) => {
+ if (!gameList) return
+
+ const index = gameList.findIndex((entry) => entry.id === game.id)
+ if (index !== -1) {
+ sectionMutated = true
+ const newList = [...gameList]
+ newList[index] = {
+ ...newList[index],
+ is_favorited: !isCurrentlyFavorited,
+ }
+ updatedSectionPages[Number(pageKey)] = newList
+ } else {
+ updatedSectionPages[Number(pageKey)] = gameList
+ }
+ })
+
+ if (!sectionMutated) {
+ return prev
+ }
+
+ return {
+ ...prev,
+ [currentSectionKey]: updatedSectionPages,
+ }
+ })
+ }
+
+ if (isCurrentlyFavorited) {
+ setGamesByPage((prev) => {
+ const favoritesPages = prev.favorites
+ if (!favoritesPages) return prev
+
+ const updatedFavorites: { [page: number]: CachedGameEntry[] } = {}
+ let favoritesMutated = false
+
+ Object.entries(favoritesPages).forEach(([pageKey, gameList]) => {
+ if (!gameList) return
+ const filtered = gameList.filter((entry) => entry.id !== game.id)
+ if (filtered.length !== gameList.length) {
+ favoritesMutated = true
+ }
+ updatedFavorites[Number(pageKey)] = filtered
+ })
+
+ if (!favoritesMutated) {
+ return prev
+ }
+
+ return {
+ ...prev,
+ favorites: updatedFavorites,
+ }
+ })
+ } else {
+ setGamesByPage((prev) => ({
+ ...prev,
+ favorites: {},
+ }))
+ }
+
+ setFetchedCache((prev) => ({
+ ...prev,
+ favorites: {},
+ }))
+ }
+
+ const updateCachedGameNames = (gameId: string, newName: string) => {
+ setGamesByPage((prev) => {
+ let mutated = false
+ const next = { ...prev }
+
+ Object.entries(prev).forEach(([sectionKey, pages]) => {
+ const sectionPages = { ...pages }
+ let sectionMutated = false
+
+ Object.entries(pages).forEach(([pageKey, gameList]) => {
+ if (!gameList) return
+ let pageMutated = false
+
+ const updatedList = gameList.map((entry) => {
+ if (entry.id === gameId) {
+ pageMutated = true
+ return {
+ ...entry,
+ label: newName,
+ custom_name: newName,
+ }
+ }
+ return entry
+ })
+
+ if (pageMutated) {
+ sectionMutated = true
+ mutated = true
+ sectionPages[Number(pageKey)] = updatedList
+ }
+ })
+
+ if (sectionMutated) {
+ next[sectionKey] = sectionPages
+ }
+ })
+
+ return mutated ? next : prev
+ })
}
- const handleSaveFavorite = (customName: string) => {
- if (favoriteModal.game) {
- addFavoriteGame(favoriteModal.game, customName)
- setFavoriteGames(getFavoritesAsWebGames())
+ const handleSaveGameName = async (newName: string) => {
+ if (!editModal.game) return
+ const trimmedName = newName.trim()
+ if (!trimmedName) return
+
+ try {
+ await updateFavoriteName(
+ editModal.game.id,
+ trimmedName,
+ editModal.game.type,
+ )
+ } catch (error) {
+ console.error('Failed to update game name', error)
+ return
}
+
+ setCustomNameOverrides((prev) => ({
+ ...prev,
+ [editModal.game!.id]: trimmedName,
+ }))
+
+ updateCachedGameNames(editModal.game.id, trimmedName)
+ setEditModal({ isOpen: false, game: null })
}
- const handleRemoveFavorite = () => {
- if (favoriteModal.game) {
- removeFavoriteGame(favoriteModal.game.id)
- setFavoriteGames(getFavoritesAsWebGames())
+ const handleDeleteCustomGame = async () => {
+ if (!editModal.game || editModal.game.type !== 'custom') return
+ const deletedGameId = editModal.game.id
+
+ try {
+ await deleteCustomGame(editModal.game.id)
+ } catch (error) {
+ console.error('Failed to delete custom game', error)
+ return
}
+
+ setGamesByPage((prev) => {
+ const next = { ...prev }
+ let mutated = false
+
+ ;['custom', 'favorites'].forEach((sectionKey) => {
+ const pages = prev[sectionKey]
+ if (!pages) return
+
+ const updatedPages: { [page: number]: CachedGameEntry[] } = {}
+ let sectionMutated = false
+
+ Object.entries(pages).forEach(([pageKey, gameList]) => {
+ if (!gameList) return
+ const filtered = gameList.filter(
+ (entry) => entry.id !== deletedGameId,
+ )
+ if (filtered.length !== gameList.length) {
+ sectionMutated = true
+ mutated = true
+ }
+ updatedPages[Number(pageKey)] = filtered
+ })
+
+ if (sectionMutated) {
+ next[sectionKey] = updatedPages
+ }
+ })
+
+ return mutated ? next : prev
+ })
+
+ setCustomNameOverrides((prev) => {
+ if (!(deletedGameId in prev)) {
+ return prev
+ }
+ const { [deletedGameId]: _removed, ...rest } = prev
+ return rest
+ })
+
+ setFavoritedGameIds((prev) => {
+ if (!prev.has(deletedGameId)) {
+ return prev
+ }
+ const next = new Set(prev)
+ next.delete(deletedGameId)
+ return next
+ })
+
+ setFetchedCache((prev) => ({
+ ...prev,
+ custom: {},
+ favorites: {},
+ }))
+
+ try {
+ const updatedFavorites = await getFavoritesAsWebGames()
+ const favoriteIds = new Set(updatedFavorites.map((f) => f.id))
+ favoriteIds.delete(deletedGameId)
+ setFavoritedGameIds(favoriteIds)
+ setFavoritesInitialized(true)
+ } catch (error) {
+ console.error('Failed to refresh favourites after deletion', error)
+ setFavoritesInitialized(true)
+ }
+
+ setEditModal({ isOpen: false, game: null })
}
- const getCurrentGames = () => {
+ const getCurrentGames = (): CachedGameEntry[] => {
if (selected === 'play') {
return gamesByPage.play[currentPage] || []
} else if (selected === 'hb') {
const gameType = hbSubsection === 'hand' ? 'hand' : 'brain'
return gamesByPage[gameType]?.[currentPage] || []
} else if (selected === 'custom') {
- return customAnalyses
+ return gamesByPage['custom']?.[currentPage] || []
} else if (selected === 'lichess') {
- return analysisLichessList
+ return analysisLichessList as CachedGameEntry[]
} else if (selected === 'favorites') {
- return favoriteGames
+ return gamesByPage.favorites[currentPage] || []
}
return []
}
@@ -419,10 +772,14 @@ export const AnalysisGameList: React.FC = ({
return analysisTournamentList ? (
-
+ {selected === 'custom' && onCustomAnalysis && (
+
+
+
+ add
+
+
+ Analyze Custom PGN/FEN
+
+
+
+ )}
+
{/* H&B Subsections */}
{selected === 'hb' && (
-
+
setHbSubsection('hand')}
- className={`flex-1 px-3 text-sm ${
+ className={`flex-1 px-3 text-sm transition-colors ${
hbSubsection === 'hand'
- ? 'bg-background-2 text-primary'
- : 'bg-background-1/50 text-secondary hover:bg-background-2'
+ ? 'bg-white/10 text-white'
+ : 'bg-white/5 text-white/80 hover:bg-white/10'
}`}
>
-
- hand_gesture
+
+ back_hand
- Hand
+ Hand
setHbSubsection('brain')}
- className={`flex-1 px-3 text-sm ${
+ className={`flex-1 px-3 text-sm transition-colors ${
hbSubsection === 'brain'
- ? 'bg-background-2 text-primary'
- : 'bg-background-1/50 text-secondary hover:bg-background-2'
+ ? 'bg-white/10 text-white'
+ : 'bg-white/5 text-white/80 hover:bg-white/10'
}`}
>
-
+
neurology
- Brain
+ Brain
@@ -524,7 +897,6 @@ export const AnalysisGameList: React.FC
= ({
selectedGameElement={
selectedGameElement as React.RefObject
}
- loadNewTournamentGame={loadNewTournamentGame}
analysisTournamentList={analysisTournamentList}
/>
))}
@@ -533,17 +905,22 @@ export const AnalysisGameList: React.FC = ({
<>
{getCurrentGames().map((game, index) => {
const selectedGame = currentId && currentId[0] === game.id
- const isFavorited = isFavoriteGame(game.id)
+ const isFavorited =
+ favoritedGameIds.has(game.id) ||
+ (!favoritesInitialized && Boolean(game.is_favorited))
+ const displayName = getDisplayName(game)
return (
-
- {selected === 'play' || selected === 'hb'
+
+ {selected === 'play' ||
+ selected === 'hb' ||
+ selected === 'favorites'
? (currentPage - 1) * 25 + index + 1
: index + 1}
@@ -551,12 +928,9 @@ export const AnalysisGameList: React.FC
= ({
{
setLoadingIndex(index)
- if (game.type === 'pgn') {
- router.push(`/analysis/${game.id}/pgn`)
- } else if (
- game.type === 'custom-pgn' ||
- game.type === 'custom-fen'
- ) {
+ if (game.type === 'lichess') {
+ router.push(`/analysis/${game.id}/lichess`)
+ } else if (game.type === 'custom') {
router.push(`/analysis/${game.id}/custom`)
} else {
router.push(`/analysis/${game.id}/${game.type}`)
@@ -567,13 +941,13 @@ export const AnalysisGameList: React.FC = ({
className="flex flex-1 cursor-pointer items-center justify-between overflow-hidden py-1"
>
-
- {game.label}
+
+ {displayName}
{selected === 'favorites' &&
(game.type === 'hand' ||
game.type === 'brain') && (
-
+
{game.type === 'hand'
? 'hand_gesture'
: 'neurology'}
@@ -581,45 +955,41 @@ export const AnalysisGameList: React.FC = ({
)}
- {selected === 'favorites' && (
-
{
- e.stopPropagation()
- handleFavoriteGame(game)
- }}
- className="flex items-center justify-center text-secondary transition hover:text-primary"
- title="Edit favourite"
+ {
+ e.stopPropagation()
+ void handleToggleFavorite(game)
+ }}
+ className={`flex items-center justify-center transition ${
+ isFavorited
+ ? 'text-yellow-400 hover:text-yellow-300'
+ : 'text-secondary hover:text-primary'
+ }`}
+ title={
+ isFavorited
+ ? 'Remove from favourites'
+ : 'Add to favourites'
+ }
+ >
+
-
- edit
-
-
- )}
- {selected !== 'favorites' && (
- {
- e.stopPropagation()
- handleFavoriteGame(game)
- }}
- className={`flex items-center justify-center transition ${
- isFavorited
- ? 'text-yellow-400 hover:text-yellow-300'
- : 'text-secondary hover:text-primary'
- }`}
- title={
- isFavorited
- ? 'Edit favourite'
- : 'Add to favourites'
- }
- >
-
- star
-
-
- )}
-
+ star
+
+
+
{
+ e.stopPropagation()
+ openEditModal(game)
+ }}
+ className="flex items-center justify-center text-white/70 transition hover:text-white"
+ title="Rename game"
+ >
+
+ edit
+
+
+
{game.result
.replace('1/2', '½')
.replace('1/2', '½')}
@@ -629,7 +999,9 @@ export const AnalysisGameList: React.FC = ({
)
})}
- {(selected === 'play' || selected === 'hb') &&
+ {(selected === 'play' ||
+ selected === 'hb' ||
+ selected === 'favorites') &&
totalPages > 1 && (
= ({
disabled={currentPage === 1}
className="flex items-center justify-center text-secondary hover:text-primary disabled:opacity-50"
>
-
+
first_page
@@ -646,11 +1018,11 @@ export const AnalysisGameList: React.FC
= ({
disabled={currentPage === 1}
className="flex items-center justify-center text-secondary hover:text-primary disabled:opacity-50"
>
-
+
arrow_back_ios
-
+
Page {currentPage} of {totalPages}
= ({
disabled={currentPage === totalPages}
className="flex items-center justify-center text-secondary hover:text-primary disabled:opacity-50"
>
-
+
arrow_forward_ios
@@ -667,7 +1039,7 @@ export const AnalysisGameList: React.FC = ({
disabled={currentPage === totalPages}
className="flex items-center justify-center text-secondary hover:text-primary disabled:opacity-50"
>
-
+
last_page
@@ -681,7 +1053,7 @@ export const AnalysisGameList: React.FC = ({
getCurrentGames().length === 0 &&
!loading && (
-
+
{selected === 'favorites'
? ' ⭐ Hit the star to favourite games...'
: 'Play more games... ^. .^₎⟆'}
@@ -689,30 +1061,19 @@ export const AnalysisGameList: React.FC = ({
)}
- {onCustomAnalysis && (
-
-
- add
-
-
- Analyze Custom PGN/FEN
-
-
- )}
+ {/* Removed bottom "Analyze Custom" button; now shown only under Custom tab */}
setFavoriteModal({ isOpen: false, game: null })}
- onSave={handleSaveFavorite}
- onRemove={
- favoriteModal.game && isFavoriteGame(favoriteModal.game.id)
- ? handleRemoveFavorite
+ isOpen={editModal.isOpen}
+ currentName={editModal.game ? getDisplayName(editModal.game) : ''}
+ onClose={() => setEditModal({ isOpen: false, game: null })}
+ onSave={handleSaveGameName}
+ onDelete={
+ editModal.game && editModal.game.type === 'custom'
+ ? handleDeleteCustomGame
: undefined
}
+ title="Edit Game"
/>
) : null
@@ -734,11 +1095,11 @@ function Header({
return (
setSelected(name)}
- className={`relative flex items-center justify-center md:py-1 ${selected === name ? 'bg-human-4/30' : 'bg-background-1/80 hover:bg-background-2'} ${name === 'favorites' ? 'px-3' : ''}`}
+ className={`relative flex items-center justify-center py-1 ${selected === name ? 'bg-white/10' : 'bg-white/5 hover:bg-white/10'} ${name === 'favorites' ? 'px-3' : ''}`}
>
{label}
@@ -746,7 +1107,7 @@ function Header({
{selected === name && (
)}
diff --git a/src/components/Analysis/AnalysisNotification.tsx b/src/components/Analysis/AnalysisNotification.tsx
index 7cc07b7d..d03e3f34 100644
--- a/src/components/Analysis/AnalysisNotification.tsx
+++ b/src/components/Analysis/AnalysisNotification.tsx
@@ -1,9 +1,9 @@
import React from 'react'
import { motion } from 'framer-motion'
-import { GameAnalysisProgress } from 'src/hooks/useAnalysisController/useAnalysisController'
+import { DeepAnalysisProgress } from 'src/types/analysis'
interface Props {
- progress: GameAnalysisProgress
+ progress: DeepAnalysisProgress
onCancel: () => void
}
@@ -11,71 +11,76 @@ export const AnalysisNotification: React.FC
= ({
progress,
onCancel,
}) => {
- const progressPercentage =
- progress.totalMoves > 0
- ? Math.round((progress.currentMoveIndex / progress.totalMoves) * 100)
- : 0
+ const hasTotals = progress.totalMoves > 0
+ const progressPercentage = hasTotals
+ ? Math.round((progress.currentMoveIndex / progress.totalMoves) * 100)
+ : 0
if (!progress.isAnalyzing) return null
return (
-
-
-
-
- network_intelligence
-
-
-
-
Analyzing Game
-
- Position {progress.currentMoveIndex} of {progress.totalMoves}
-
-
-
-
-
- {progressPercentage}%
+
+
+
+ neurology
-
- close
-
-
-
-
-
+
+
+
+ Analyzing Game
+
+
+
+ {hasTotals
+ ? `Position ${Math.max(progress.currentMoveIndex, 1)} of ${progress.totalMoves}`
+ : 'Calibrating engine positions…'}
+
+
+
+ close
+
- {progress.currentMove && (
-
-
- Current:{' '}
-
- {progress.currentMove}
-
-
+
+
+
+
+
+
+ {progressPercentage}%
+
- )}
+ {progress.currentMove && (
+
+
+ Current Position
+
+
+ {progress.currentMove}
+
+
+ )}
+
)
}
diff --git a/src/components/Analysis/AnalysisSidebar.tsx b/src/components/Analysis/AnalysisSidebar.tsx
index e84a54a3..2198a89b 100644
--- a/src/components/Analysis/AnalysisSidebar.tsx
+++ b/src/components/Analysis/AnalysisSidebar.tsx
@@ -3,13 +3,22 @@ import {
Highlight,
BlunderMeter,
MovesByRating,
+ SimplifiedAnalysisOverview,
+ SimplifiedBlunderMeter,
} from 'src/components/Analysis'
import { motion } from 'framer-motion'
import type { DrawShape } from 'chessground/draw'
import { Dispatch, SetStateAction, useCallback, useMemo } from 'react'
+import type { ComponentProps } from 'react'
+import { useLocalStorage } from 'src/hooks'
import { useAnalysisController } from 'src/hooks/useAnalysisController'
import type { MaiaEvaluation, StockfishEvaluation } from 'src/types'
+type AnalysisViewMode = 'simple' | 'detailed'
+type HighlightBoardDescription = ComponentProps<
+ typeof Highlight
+>['boardDescription']
+
interface Props {
hover: (move?: string) => void
makeMove: (move: string) => void
@@ -52,7 +61,6 @@ export const AnalysisSidebar: React.FC
= ({
handleToggleAnalysis,
itemVariants,
}) => {
- // Mock handlers for when analysis is disabled
const emptyBlunderMeterData = useMemo(
() => ({
goodMoves: { moves: [], probability: 0 },
@@ -69,283 +77,294 @@ export const AnalysisSidebar: React.FC = ({
}),
[],
)
+
const mockHover = useCallback(() => void 0, [])
const mockSetHoverArrow = useCallback(() => void 0, [])
const mockMakeMove = useCallback(() => void 0, [])
- return (
-
- {/* Analysis Toggle Bar */}
-
+ const [analysisViewMode, setAnalysisViewMode] =
+ useLocalStorage
('maia-analysis-view-mode', 'simple')
+ const isSimplifiedView = analysisViewMode !== 'detailed'
+
+ const handleToggleViewMode = useCallback(() => {
+ setAnalysisViewMode(isSimplifiedView ? 'detailed' : 'simple')
+ }, [isSimplifiedView, setAnalysisViewMode])
+
+ const highlightMoveEvaluation = analysisEnabled
+ ? (controller.moveEvaluation as {
+ maia?: MaiaEvaluation
+ stockfish?: StockfishEvaluation
+ })
+ : {
+ maia: undefined,
+ stockfish: undefined,
+ }
+
+ const highlightBoardDescription: HighlightBoardDescription = analysisEnabled
+ ? controller.boardDescription
+ : {
+ segments: [
+ {
+ type: 'text',
+ content:
+ 'Analysis is disabled. Enable analysis to see detailed move evaluations and recommendations.',
+ },
+ ],
+ }
+
+ const highlightProps: ComponentProps = {
+ hover: analysisEnabled ? hover : mockHover,
+ makeMove: analysisEnabled ? makeMove : mockMakeMove,
+ currentMaiaModel: controller.currentMaiaModel,
+ setCurrentMaiaModel: controller.setCurrentMaiaModel,
+ recommendations: analysisEnabled
+ ? controller.moveRecommendations
+ : emptyRecommendations,
+ moveEvaluation: highlightMoveEvaluation,
+ colorSanMapping: analysisEnabled ? controller.colorSanMapping : {},
+ boardDescription: highlightBoardDescription,
+ currentNode: controller.currentNode ?? undefined,
+ simplified: isSimplifiedView,
+ }
+
+ const simplifiedBlunderMeterProps: ComponentProps<
+ typeof SimplifiedBlunderMeter
+ > = {
+ hover: analysisEnabled ? hover : mockHover,
+ makeMove: analysisEnabled ? makeMove : mockMakeMove,
+ data: analysisEnabled ? controller.blunderMeter : emptyBlunderMeterData,
+ colorSanMapping: analysisEnabled ? controller.colorSanMapping : {},
+ moveEvaluation: analysisEnabled ? controller.moveEvaluation : undefined,
+ playerToMove: analysisEnabled ? (controller.currentNode?.turn ?? 'w') : 'w',
+ }
+
+ const blunderMeterProps: ComponentProps = {
+ ...simplifiedBlunderMeterProps,
+ }
+
+ const moveMapProps = {
+ moveMap: analysisEnabled ? controller.moveMap : undefined,
+ colorSanMapping: analysisEnabled ? controller.colorSanMapping : {},
+ setHoverArrow: analysisEnabled ? setHoverArrow : mockSetHoverArrow,
+ makeMove: analysisEnabled ? makeMove : mockMakeMove,
+ playerToMove: analysisEnabled ? (controller.currentNode?.turn ?? 'w') : 'w',
+ }
+
+ const movesByRatingProps = {
+ moves: analysisEnabled ? controller.movesByRating : undefined,
+ colorSanMapping: analysisEnabled ? controller.colorSanMapping : {},
+ }
+
+ const renderHeader = (
+ variant: 'desktop' | 'mobile',
+ extraClasses?: string,
+ ) => {
+ const containerClasses = [
+ 'flex h-10 min-h-10 items-center justify-between border-b border-glass-border bg-transparent text-white/90',
+ variant === 'desktop' ? 'px-4' : 'px-3',
+ variant === 'mobile' ? 'backdrop-blur-md' : '',
+ extraClasses ?? '',
+ ]
+ .filter(Boolean)
+ .join(' ')
+
+ const buttonBase =
+ variant === 'desktop'
+ ? 'flex items-center gap-1 rounded-md border border-glass-border bg-glass px-2 py-1 text-xs transition-colors hover:bg-glass-stronger'
+ : 'flex items-center gap-1 rounded-md border border-glass-border bg-glass px-2 py-1 text-xs transition-colors'
+
+ const viewButtonClass = `${buttonBase} ${
+ isSimplifiedView ? 'text-white' : 'text-white/80'
+ }`
+ const visibilityButtonClass = `${buttonBase} ${
+ analysisEnabled ? 'text-white' : 'text-white/80'
+ }`
+
+ return (
+
+
+
+ analytics
+
+
+ Analysis
+
+
- analytics
-
Analysis
+
+
+ {isSimplifiedView ? 'expand_all' : 'collapse_all'}
+
+
+ {isSimplifiedView ? 'Expand' : 'Collapse'}
+
+
+
+
+ {analysisEnabled ? 'visibility_off' : 'visibility'}
+
+
+ {analysisEnabled ? 'Hide' : 'Show'}
+
+
-
-
- {analysisEnabled ? 'visibility' : 'visibility_off'}
+
+ )
+ }
+
+ const renderDisabledOverlay = (
+ message: string,
+ options: { offsetTop?: boolean } = {},
+ ) => {
+ const offsetClasses = options.offsetTop
+ ? 'bottom-0 left-0 right-0 top-10'
+ : 'inset-0'
+
+ return (
+
+
+
+ lock
- {analysisEnabled ? 'Visible' : 'Hidden'}
-
+
+ Analysis Disabled
+
+
{message}
+
+ )
+ }
- {/* Large screens : 2-row layout */}
-
- {/* Combined Highlight + MovesByRating container */}
-
-
-
-
-
-
-
-
+ const simplifiedLayout = (
+ <>
+
+
+ {renderHeader('desktop')}
+
+
- {!analysisEnabled && (
-
-
-
- lock
-
-
Analysis Disabled
-
- Enable analysis to see move evaluations
-
+ {!analysisEnabled &&
+ renderDisabledOverlay('Enable analysis to see move evaluations', {
+ offsetTop: true,
+ })}
+
+
+
+
+ {renderHeader('mobile', 'absolute left-0 top-0 z-10 w-full')}
+
+
+
+ {!analysisEnabled &&
+ renderDisabledOverlay('Enable analysis to see move evaluations', {
+ offsetTop: true,
+ })}
+
+
+ >
+ )
+
+ const detailedLayout = (
+ <>
+
+
+
+ {renderHeader('desktop')}
+
- )}
+
+ {!analysisEnabled &&
+ renderDisabledOverlay('Enable analysis to see move evaluations', {
+ offsetTop: true,
+ })}
- {/* MoveMap + BlunderMeter container */}
-
+
-
+
-
- {!analysisEnabled && (
-
-
-
- lock
-
-
Analysis Disabled
-
- Enable analysis to see position evaluation
-
-
-
- )}
+
+ {!analysisEnabled &&
+ renderDisabledOverlay('Enable analysis to see position evaluation')}
- {/* Smaller screens: 3-row layout */}
-
- {/* Row 1: Combined Highlight + BlunderMeter container */}
-
-
-
+
+
+ {renderHeader('mobile', 'absolute left-0 top-0 z-10 w-full')}
+
+
-
+
- {!analysisEnabled && (
-
-
-
- lock
-
-
Analysis Disabled
-
- Enable analysis to see move evaluations
-
-
-
- )}
+ {!analysisEnabled &&
+ renderDisabledOverlay('Enable analysis to see move evaluations', {
+ offsetTop: true,
+ })}
- {/* Row 2: MoveMap */}
-
+
- {!analysisEnabled && (
-
-
-
- lock
-
-
Analysis Disabled
-
- Enable analysis to see position evaluation
-
-
-
- )}
+ {!analysisEnabled &&
+ renderDisabledOverlay('Enable analysis to see position evaluation')}
- {/* Row 3: MovesByRating */}
-
-
+
+
+ {!analysisEnabled &&
+ renderDisabledOverlay('Enable analysis to see move evaluations')}
- {!analysisEnabled && (
-
-
-
- lock
-
-
Analysis Disabled
-
- Enable analysis to see move evaluations
-
-
-
- )}
+ >
+ )
+
+ return (
+
+ {isSimplifiedView ? simplifiedLayout : detailedLayout}
)
}
diff --git a/src/components/Analysis/BlunderMeter.tsx b/src/components/Analysis/BlunderMeter.tsx
index 00636ce7..e468b9bd 100644
--- a/src/components/Analysis/BlunderMeter.tsx
+++ b/src/components/Analysis/BlunderMeter.tsx
@@ -20,6 +20,7 @@ interface Props {
maia?: MaiaEvaluation
stockfish?: StockfishEvaluation
} | null
+ playerToMove?: 'w' | 'b'
}
export const BlunderMeter: React.FC
= ({
@@ -29,6 +30,7 @@ export const BlunderMeter: React.FC = ({
colorSanMapping,
showContainer = true,
moveEvaluation,
+ playerToMove = 'w',
}: Props) => {
const { isMobile } = useContext(WindowSizeContext)
@@ -40,6 +42,7 @@ export const BlunderMeter: React.FC = ({
colorSanMapping={colorSanMapping}
showContainer={showContainer}
moveEvaluation={moveEvaluation}
+ playerToMove={playerToMove}
/>
) : (
= ({
colorSanMapping={colorSanMapping}
showContainer={showContainer}
moveEvaluation={moveEvaluation}
+ playerToMove={playerToMove}
/>
)
}
@@ -60,11 +64,12 @@ const DesktopBlunderMeter: React.FC = ({
colorSanMapping,
showContainer = true,
moveEvaluation,
+ playerToMove = 'w',
}: Props) => {
return (
Blunder Meter
@@ -79,6 +84,7 @@ const DesktopBlunderMeter: React.FC
= ({
probability={data.goodMoves.probability}
colorSanMapping={colorSanMapping}
moveEvaluation={moveEvaluation}
+ playerToMove={playerToMove}
/>
= ({
probability={data.okMoves.probability}
colorSanMapping={colorSanMapping}
moveEvaluation={moveEvaluation}
+ playerToMove={playerToMove}
/>
= ({
probability={data.blunderMoves.probability}
colorSanMapping={colorSanMapping}
moveEvaluation={moveEvaluation}
+ playerToMove={playerToMove}
/>
@@ -115,11 +123,12 @@ const MobileBlunderMeter: React.FC = ({
colorSanMapping,
showContainer,
moveEvaluation,
+ playerToMove = 'w',
}: Props) => {
return (
Blunder Meter
@@ -166,6 +175,8 @@ const MobileBlunderMeter: React.FC
= ({
makeMove={makeMove}
colorSanMapping={colorSanMapping}
moveEvaluation={moveEvaluation}
+ playerToMove={playerToMove}
+ probability={data.goodMoves.probability}
/>
= ({
makeMove={makeMove}
colorSanMapping={colorSanMapping}
moveEvaluation={moveEvaluation}
+ playerToMove={playerToMove}
+ probability={data.okMoves.probability}
/>
= ({
makeMove={makeMove}
colorSanMapping={colorSanMapping}
moveEvaluation={moveEvaluation}
+ playerToMove={playerToMove}
+ probability={data.blunderMoves.probability}
/>
@@ -199,6 +214,8 @@ function MovesList({
makeMove,
colorSanMapping,
moveEvaluation,
+ playerToMove = 'w',
+ probability,
}: {
title: string
textColor: string
@@ -210,6 +227,8 @@ function MovesList({
maia?: MaiaEvaluation
stockfish?: StockfishEvaluation
} | null
+ playerToMove?: 'w' | 'b'
+ probability: number
}) {
const { isMobile } = useContext(WindowSizeContext)
const [tooltipData, setTooltipData] = useState<{
@@ -227,7 +246,17 @@ function MovesList({
}, [colorSanMapping])
const filteredMoves = () => {
- return moves.slice(0, 6).filter((move) => move.probability >= 8)
+ const threshold = 5
+ const filtered = moves
+ .slice(0, 6)
+ .filter((move) => move.probability >= threshold)
+
+ // If category has meaningful probability but no moves meet threshold, show top move
+ if (filtered.length === 0 && probability >= 10 && moves.length > 0) {
+ return [moves[0]]
+ }
+
+ return filtered
}
const handleMouseEnter = (move: string, event: React.MouseEvent) => {
@@ -309,6 +338,8 @@ function MovesList({
stockfishCpRelative={
moveEvaluation.stockfish?.cp_relative_vec?.[tooltipData.move]
}
+ stockfishMate={moveEvaluation.stockfish?.mate_vec?.[tooltipData.move]}
+ playerToMove={playerToMove}
position={tooltipData.position}
onClickMove={isMobile ? handleTooltipClick : undefined}
/>
@@ -365,6 +396,7 @@ function Meter({
probability,
colorSanMapping,
moveEvaluation,
+ playerToMove = 'w',
}: {
title: string
textColor: string
@@ -378,6 +410,7 @@ function Meter({
maia?: MaiaEvaluation
stockfish?: StockfishEvaluation
} | null
+ playerToMove?: 'w' | 'b'
}) {
const { isMobile } = useContext(WindowSizeContext)
const [tooltipData, setTooltipData] = useState<{
@@ -395,7 +428,17 @@ function Meter({
}, [colorSanMapping])
const filteredMoves = () => {
- return moves.slice(0, 6).filter((move) => move.probability >= 8)
+ const threshold = 8
+ const filtered = moves
+ .slice(0, 6)
+ .filter((move) => move.probability >= threshold)
+
+ // If category has meaningful probability but no moves meet threshold, show top move
+ if (filtered.length === 0 && probability >= 10 && moves.length > 0) {
+ return [moves[0]]
+ }
+
+ return filtered
}
const handleMouseEnter = (move: string, event: React.MouseEvent) => {
@@ -493,6 +536,8 @@ function Meter({
stockfishCpRelative={
moveEvaluation.stockfish?.cp_relative_vec?.[tooltipData.move]
}
+ stockfishMate={moveEvaluation.stockfish?.mate_vec?.[tooltipData.move]}
+ playerToMove={playerToMove}
position={tooltipData.position}
onClickMove={isMobile ? handleTooltipClick : undefined}
/>
diff --git a/src/components/Analysis/BroadcastAnalysis.tsx b/src/components/Analysis/BroadcastAnalysis.tsx
new file mode 100644
index 00000000..06a3df95
--- /dev/null
+++ b/src/components/Analysis/BroadcastAnalysis.tsx
@@ -0,0 +1,532 @@
+import React, {
+ useMemo,
+ useState,
+ useEffect,
+ useCallback,
+ useContext,
+} from 'react'
+import { motion } from 'framer-motion'
+import type { Key } from 'chessground/types'
+import { Chess, PieceSymbol } from 'chess.ts'
+import type { DrawShape } from 'chessground/draw'
+
+import { WindowSizeContext } from 'src/contexts'
+import { MAIA_MODELS } from 'src/constants/common'
+import { GameInfo } from 'src/components/Common/GameInfo'
+import { GameBoard } from 'src/components/Board/GameBoard'
+import { PlayerInfo } from 'src/components/Common/PlayerInfo'
+import { MovesContainer } from 'src/components/Board/MovesContainer'
+import { LiveGame, GameNode, BroadcastStreamController } from 'src/types'
+import { BoardController } from 'src/components/Board/BoardController'
+import { PromotionOverlay } from 'src/components/Board/PromotionOverlay'
+import { AnalysisSidebar } from 'src/components/Analysis'
+import { ConfigurableScreens } from 'src/components/Analysis/ConfigurableScreens'
+import { BroadcastGameList } from 'src/components/Analysis/BroadcastGameList'
+import { useAnalysisController } from 'src/hooks/useAnalysisController'
+
+interface Props {
+ game: LiveGame
+ broadcastController: BroadcastStreamController & {
+ currentLiveGame: LiveGame | null
+ }
+ analysisController: ReturnType
+}
+
+export const BroadcastAnalysis: React.FC = ({
+ game,
+ broadcastController,
+ analysisController,
+}) => {
+ const { width } = useContext(WindowSizeContext)
+ const isMobile = useMemo(() => width > 0 && width <= 670, [width])
+
+ const [hoverArrow, setHoverArrow] = useState(null)
+ const [currentSquare, setCurrentSquare] = useState(null)
+ const [promotionFromTo, setPromotionFromTo] = useState<
+ [string, string] | null
+ >(null)
+
+ useEffect(() => {
+ setHoverArrow(null)
+ }, [analysisController.currentNode])
+
+ const hover = (move?: string) => {
+ if (move) {
+ setHoverArrow({
+ orig: move.slice(0, 2) as Key,
+ dest: move.slice(2, 4) as Key,
+ brush: 'green',
+ modifiers: {
+ lineWidth: 10,
+ },
+ })
+ } else {
+ setHoverArrow(null)
+ }
+ }
+
+ const makeMove = (move: string) => {
+ if (!analysisController.currentNode || !game.tree) return
+
+ const chess = new Chess(analysisController.currentNode.fen)
+ const moveAttempt = chess.move({
+ from: move.slice(0, 2),
+ to: move.slice(2, 4),
+ promotion: move[4] ? (move[4] as PieceSymbol) : undefined,
+ })
+
+ if (moveAttempt) {
+ const newFen = chess.fen()
+ const moveString =
+ moveAttempt.from +
+ moveAttempt.to +
+ (moveAttempt.promotion ? moveAttempt.promotion : '')
+ const san = moveAttempt.san
+
+ if (analysisController.currentNode.mainChild?.move === moveString) {
+ analysisController.goToNode(analysisController.currentNode.mainChild)
+ } else {
+ const newVariation = game.tree
+ .getLastMainlineNode()
+ .addChild(
+ newFen,
+ moveString,
+ san,
+ false,
+ analysisController.currentMaiaModel,
+ )
+ analysisController.goToNode(newVariation)
+ }
+ }
+ }
+
+ const onPlayerMakeMove = useCallback(
+ (playedMove: [string, string] | null) => {
+ if (!playedMove) return
+
+ const availableMoves: { from: string; to: string }[] = []
+ for (const [from, tos] of analysisController.availableMoves.entries()) {
+ for (const to of tos as string[]) {
+ availableMoves.push({ from, to })
+ }
+ }
+
+ const matching = availableMoves.filter((m) => {
+ return m.from === playedMove[0] && m.to === playedMove[1]
+ })
+
+ if (matching.length > 1) {
+ setPromotionFromTo(playedMove)
+ return
+ }
+
+ const moveUci = playedMove[0] + playedMove[1]
+ makeMove(moveUci)
+ },
+ [analysisController.availableMoves],
+ )
+
+ const onPlayerSelectPromotion = useCallback(
+ (piece: string) => {
+ if (!promotionFromTo) {
+ return
+ }
+ setPromotionFromTo(null)
+ const moveUci = promotionFromTo[0] + promotionFromTo[1] + piece
+ makeMove(moveUci)
+ },
+ [promotionFromTo, setPromotionFromTo],
+ )
+
+ const launchContinue = useCallback(() => {
+ const fen = analysisController.currentNode?.fen as string
+ const url = '/play' + '?fen=' + encodeURIComponent(fen)
+ window.open(url)
+ }, [analysisController.currentNode])
+
+ const currentPlayer = useMemo(() => {
+ if (!analysisController.currentNode) return 'white'
+ const chess = new Chess(analysisController.currentNode.fen)
+ return chess.turn() === 'w' ? 'white' : 'black'
+ }, [analysisController.currentNode])
+
+ const NestedGameInfo = () => (
+
+
+ {[game.whitePlayer, game.blackPlayer].map((player, index) => (
+
+
+
+
+ {player.name}
+
+
+ {player.rating ? <>({player.rating})> : null}
+
+
+ {game.termination?.winner === (index == 0 ? 'white' : 'black') ? (
+
1
+ ) : game.termination?.winner !== 'none' ? (
+
0
+ ) : game.termination === undefined ? (
+ <>>
+ ) : (
+
½
+ )}
+
+ ))}
+
+
+ {broadcastController.currentBroadcast?.tour.name}
+ {broadcastController.currentRound && (
+ <> • {broadcastController.currentRound.name}>
+ )}
+
+
+
+
+
+
+
{game.whitePlayer.name}
+ {game.whitePlayer.rating && (
+
({game.whitePlayer.rating})
+ )}
+
+
+ {broadcastController.broadcastState.isLive && !game.termination ? (
+ LIVE
+ ) : game.termination?.winner === 'none' ? (
+ ½-½
+ ) : (
+
+
+ {game.termination?.winner === 'white' ? '1' : '0'}
+
+ -
+
+ {game.termination?.winner === 'black' ? '1' : '0'}
+
+
+ )}
+
+
+
+
{game.blackPlayer.name}
+ {game.blackPlayer.rating && (
+
({game.blackPlayer.rating})
+ )}
+
+
+
+ )
+
+ const containerVariants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: {
+ duration: 0.2,
+ staggerChildren: 0.05,
+ },
+ },
+ }
+
+ const itemVariants = {
+ hidden: {
+ opacity: 0,
+ y: 4,
+ },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: {
+ duration: 0.25,
+ ease: [0.25, 0.46, 0.45, 0.94],
+ type: 'tween',
+ },
+ },
+ exit: {
+ opacity: 0,
+ y: -4,
+ transition: {
+ duration: 0.2,
+ ease: [0.25, 0.46, 0.45, 0.94],
+ type: 'tween',
+ },
+ },
+ }
+
+ const desktopLayout = (
+
+
+
+
+
+
+
+
{
+ const clock =
+ analysisController.orientation === 'white'
+ ? broadcastController.currentGame?.blackClock
+ : broadcastController.currentGame?.whiteClock
+ console.log('Top PlayerInfo clock data:', {
+ orientation: analysisController.orientation,
+ currentGame:
+ broadcastController.currentGame?.white +
+ ' vs ' +
+ broadcastController.currentGame?.black,
+ whiteClock: broadcastController.currentGame?.whiteClock,
+ blackClock: broadcastController.currentGame?.blackClock,
+ selectedClock: clock,
+ })
+ return clock
+ })()}
+ />
+
+
{
+ const baseShapes = [...analysisController.arrows]
+ if (hoverArrow) {
+ baseShapes.push(hoverArrow)
+ }
+ return baseShapes
+ })()}
+ currentNode={analysisController.currentNode as GameNode}
+ orientation={analysisController.orientation}
+ onPlayerMakeMove={onPlayerMakeMove}
+ goToNode={analysisController.goToNode}
+ gameTree={game.tree}
+ />
+ {promotionFromTo ? (
+
+ ) : null}
+
+
+
+
+
+
{
+ // Analysis toggle not needed for broadcast - always enabled
+ }}
+ itemVariants={itemVariants}
+ />
+
+
+ )
+
+ const mobileLayout = (
+
+
+
+
+
+
+
+
{
+ const baseShapes = [...analysisController.arrows]
+ if (hoverArrow) {
+ baseShapes.push(hoverArrow)
+ }
+ return baseShapes
+ })()}
+ currentNode={analysisController.currentNode as GameNode}
+ orientation={analysisController.orientation}
+ onPlayerMakeMove={onPlayerMakeMove}
+ goToNode={analysisController.goToNode}
+ gameTree={game.tree}
+ />
+ {promotionFromTo ? (
+
+ ) : null}
+
+
+
+
+
+
+ )
+
+ return {isMobile ? mobileLayout : desktopLayout}
+}
diff --git a/src/components/Analysis/BroadcastGameList.tsx b/src/components/Analysis/BroadcastGameList.tsx
new file mode 100644
index 00000000..66301194
--- /dev/null
+++ b/src/components/Analysis/BroadcastGameList.tsx
@@ -0,0 +1,236 @@
+import React, { useState, useMemo, useEffect } from 'react'
+import { motion } from 'framer-motion'
+import { BroadcastStreamController, BroadcastGame } from 'src/types'
+
+interface BroadcastGameListProps {
+ broadcastController: BroadcastStreamController
+ onGameSelected?: () => void
+ embedded?: boolean
+}
+
+export const BroadcastGameList: React.FC = ({
+ broadcastController,
+ onGameSelected,
+ embedded = false,
+}) => {
+ const [selectedRoundId, setSelectedRoundId] = useState(
+ broadcastController.currentRound?.id || '',
+ )
+
+ // Sync selectedRoundId when currentRound changes
+ useEffect(() => {
+ if (
+ broadcastController.currentRound?.id &&
+ broadcastController.currentRound.id !== selectedRoundId
+ ) {
+ setSelectedRoundId(broadcastController.currentRound.id)
+ }
+ }, [broadcastController.currentRound?.id, selectedRoundId])
+
+ const handleRoundChange = (roundId: string) => {
+ setSelectedRoundId(roundId)
+ broadcastController.selectRound(roundId)
+ }
+
+ const handleGameSelect = (game: BroadcastGame) => {
+ broadcastController.selectGame(game.id)
+ onGameSelected?.()
+ }
+
+ const currentGames = useMemo(() => {
+ if (!broadcastController.roundData?.games) {
+ return []
+ }
+ return Array.from(broadcastController.roundData.games.values())
+ }, [broadcastController.roundData?.games])
+
+ const getGameStatus = (game: BroadcastGame) => {
+ if (game.result === '*') {
+ return { status: 'Live', color: 'text-red-400' }
+ } else if (game.result === '1-0') {
+ return { status: '1-0', color: 'text-primary' }
+ } else if (game.result === '0-1') {
+ return { status: '0-1', color: 'text-primary' }
+ } else if (game.result === '1/2-1/2') {
+ return { status: '½-½', color: 'text-primary' }
+ }
+ return { status: game.result, color: 'text-secondary' }
+ }
+
+ const formatPlayerName = (name: string, elo?: number) => {
+ const displayName = name.length > 12 ? name.substring(0, 12) + '...' : name
+ return elo ? `${displayName} (${elo})` : displayName
+ }
+
+ return (
+
+
+
+
+ {broadcastController.currentBroadcast?.tour.name ||
+ 'Live Broadcast'}
+
+
+
+ {/* Round Selector */}
+ {broadcastController.currentBroadcast && (
+
handleRoundChange(e.target.value)}
+ className="w-full rounded-md border border-glass-border bg-glass px-2 py-1 text-xs text-white/90 backdrop-blur-sm focus:outline-none focus:ring-1 focus:ring-human-4"
+ >
+ {broadcastController.currentBroadcast.rounds.map((round) => (
+
+ {round.name} {round.ongoing ? '(Live)' : ''}
+
+ ))}
+
+ )}
+
+ {/* Connection Status */}
+ {broadcastController.broadcastState.error && (
+
+
+ Connection Error
+
+ Retry
+
+
+
+ )}
+
+ {broadcastController.broadcastState.isConnecting && (
+
+ )}
+
+
+
+ {currentGames.length === 0 ? (
+
+
+
+ live_tv
+
+
+ {broadcastController.broadcastState.isConnecting
+ ? 'Loading games...'
+ : broadcastController.currentRound?.ongoing
+ ? 'No games available'
+ : 'Round not started yet'}
+
+
+
+ ) : (
+ <>
+ {currentGames.map((game, index) => {
+ const isSelected = broadcastController.currentGame?.id === game.id
+ const gameStatus = getGameStatus(game)
+
+ return (
+
+
+
handleGameSelect(game)}
+ className="flex flex-1 cursor-pointer flex-col items-start justify-center overflow-hidden px-2 py-2"
+ >
+
+
+
+
+
+ {formatPlayerName(game.white, game.whiteElo)}
+
+
+
+
+
+ {formatPlayerName(game.black, game.blackElo)}
+
+
+
+
+
+ {gameStatus.status}
+
+ {game.result === '*' && (
+
+ )}
+
+
+
+
+ )
+ })}
+ >
+ )}
+
+
+ {/* Footer with broadcast info */}
+
+
+ )
+}
diff --git a/src/components/Analysis/ConfigurableScreens.tsx b/src/components/Analysis/ConfigurableScreens.tsx
index 1447afd6..bc8b45a8 100644
--- a/src/components/Analysis/ConfigurableScreens.tsx
+++ b/src/components/Analysis/ConfigurableScreens.tsx
@@ -6,7 +6,7 @@ import { ExportGame } from 'src/components/Common/ExportGame'
import {
AnalyzedGame,
GameNode,
- LearnFromMistakesState,
+ LearnFromMistakesConfiguration,
MistakePosition,
} from 'src/types'
@@ -28,7 +28,7 @@ interface Props {
status: 'saving' | 'unsaved' | 'saved'
}
// Learn from mistakes props
- learnFromMistakesState?: LearnFromMistakesState
+ learnFromMistakesState?: LearnFromMistakesConfiguration
learnFromMistakesCurrentInfo?: {
mistake: MistakePosition
progress: string
@@ -82,8 +82,8 @@ export const ConfigurableScreens: React.FC = ({
(learnFromMistakesCurrentInfo || learnFromMistakesState.showPlayerSelection)
) {
return (
-
-
+
+
= ({
)
}
- // Normal state with configure/export tabs
return (
-
-
+
+
{screens.map((s) => {
const selected = s.id === screen.id
return (
@@ -150,7 +149,7 @@ export const ConfigurableScreens: React.FC
= ({
)
})}
-
+
{screen.id === 'configure' ? (
= ({
isLearnFromMistakesActive = false,
autoSave,
}: Props) => {
- const isCustomGame = game.type === 'custom-pgn' || game.type === 'custom-fen'
-
return (
-
+
-
Analyze using:
-
setCurrentMaiaModel(e.target.value)}
- >
- {MAIA_MODELS.map((model) => (
-
- {model.replace('maia_kdd_', 'Maia ')}
-
- ))}
-
+
Analyze using:
+
+ setCurrentMaiaModel(e.target.value)}
+ >
+ {MAIA_MODELS.map((model) => (
+
+ {model.replace('maia_kdd_', 'Maia ')}
+
+ ))}
+
+
+ keyboard_arrow_down
+
+
-
{onAnalyzeEntireGame && (
-
+
network_intelligence
@@ -78,10 +81,12 @@ export const ConfigureAnalysis: React.FC = ({
-
school
+
+ school
+
{isLearnFromMistakesActive
? 'Learning in progress...'
@@ -90,48 +95,45 @@ export const ConfigureAnalysis: React.FC = ({
)}
- {autoSave &&
- game.type !== 'custom-pgn' &&
- game.type !== 'custom-fen' &&
- game.type !== 'tournament' && (
-
-
- {autoSave.status === 'saving' && (
- <>
-
-
- Saving analysis...
-
- >
- )}
- {autoSave.status === 'unsaved' && (
- <>
-
- sync_problem
-
-
- Unsaved analysis. Will auto-save...
-
- >
- )}
- {autoSave.status === 'saved' && (
- <>
-
- cloud_done
-
-
- Analysis auto-saved
-
- >
- )}
-
+ {autoSave && game.type !== 'tournament' && (
+
+
+ {autoSave.status === 'saving' && (
+ <>
+
+
+ Saving analysis...
+
+ >
+ )}
+ {autoSave.status === 'unsaved' && (
+ <>
+
+ sync_problem
+
+
+ Unsaved analysis. Will auto-save...
+
+ >
+ )}
+ {autoSave.status === 'saved' && (
+ <>
+
+ cloud_done
+
+
+ Analysis auto-saved
+
+ >
+ )}
- )}
- {isCustomGame && onDeleteCustomGame && (
+
+ )}
+ {game.type === 'custom' && onDeleteCustomGame && (
Delete this stored Custom Game
diff --git a/src/components/Analysis/CustomAnalysisModal.tsx b/src/components/Analysis/CustomAnalysisModal.tsx
index 9aca1026..b2c85d95 100644
--- a/src/components/Analysis/CustomAnalysisModal.tsx
+++ b/src/components/Analysis/CustomAnalysisModal.tsx
@@ -1,7 +1,7 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
+import { motion } from 'framer-motion'
import { Chess } from 'chess.ts'
import toast from 'react-hot-toast'
-import { ModalContainer } from '../Common/ModalContainer'
interface Props {
onSubmit: (type: 'pgn' | 'fen', data: string, name?: string) => void
@@ -42,135 +42,168 @@ export const CustomAnalysisModal: React.FC
= ({ onSubmit, onClose }) => {
const examplePGN = `1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Bb7 10. d4 Re8`
const exampleFEN = `r1bqkb1r/pppp1ppp/2n2n2/1B2p3/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4`
- return (
-
-
- {/* Header */}
-
-
-
Custom Analysis
-
- Import a chess game from PGN notation or analyze a specific
- position using FEN notation
-
-
-
- close
-
-
+ useEffect(() => {
+ const originalOverflow = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => {
+ document.body.style.overflow = originalOverflow
+ }
+ }, [])
- {/* Content */}
-
-
- {/* Mode selector */}
-
-
- Import Type:
-
-
-
setMode('pgn')}
- >
- PGN Game
-
-
setMode('fen')}
- >
- FEN Position
-
+ return (
+ <>
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
+
+
+
e.stopPropagation()}
+ >
+
+
+ {/* Header */}
+
+
+
+ Custom Analysis
+
+
+ Import a chess game from PGN notation or analyze a specific
+ position using FEN notation
+
-
-
-
-
- Name (optional):
-
- setName(e.target.value)}
- />
+ close
+
-
-
- {mode === 'pgn' ? 'PGN Data:' : 'FEN Position:'}
-
-
-
- {/* Actions */}
-
-
- Cancel
-
-
- Analyze
-
-
+
-
+ >
)
}
diff --git a/src/components/Analysis/Highlight.tsx b/src/components/Analysis/Highlight.tsx
index d865021b..4a6d68ed 100644
--- a/src/components/Analysis/Highlight.tsx
+++ b/src/components/Analysis/Highlight.tsx
@@ -1,3 +1,5 @@
+import { Chess } from 'chess.ts'
+import { cpToWinrate } from 'src/lib'
import { MoveTooltip } from './MoveTooltip'
import { InteractiveDescription } from './InteractiveDescription'
import { useState, useEffect, useRef, useContext } from 'react'
@@ -8,7 +10,6 @@ import {
ColorSanMapping,
GameNode,
} from 'src/types'
-import { cpToWinrate } from 'src/lib/stockfish'
import { MAIA_MODELS } from 'src/constants/common'
import { WindowSizeContext } from 'src/contexts'
@@ -39,6 +40,7 @@ interface Props {
boardDescription: { segments: DescriptionSegment[] }
currentNode?: GameNode
isHomePage?: boolean
+ simplified?: boolean
}
export const Highlight: React.FC = ({
@@ -52,14 +54,106 @@ export const Highlight: React.FC = ({
boardDescription,
currentNode,
isHomePage = false,
+ simplified = false,
}: Props) => {
const { isMobile } = useContext(WindowSizeContext)
+
+ // Check if current position is checkmate (independent of Stockfish analysis)
+ const isCurrentPositionCheckmate = currentNode
+ ? (() => {
+ try {
+ const chess = new Chess(currentNode.fen)
+ return chess.inCheckmate()
+ } catch {
+ return false
+ }
+ })()
+ : false
+
+ const currentTurn: 'w' | 'b' =
+ currentNode?.turn || (recommendations.isBlackTurn ? 'b' : 'w')
+
+ const formatMateDisplay = (mateValue: number) => {
+ const deliveringColor =
+ mateValue > 0 ? currentTurn : currentTurn === 'w' ? 'b' : 'w'
+ const prefix = deliveringColor === 'w' ? '+' : '-'
+ return `${prefix}M${Math.abs(mateValue)}`
+ }
+
+ const getStockfishEvalDisplay = () => {
+ if (!moveEvaluation?.stockfish) {
+ return '...'
+ }
+
+ const { stockfish } = moveEvaluation
+ const isBlackTurn = currentTurn === 'b'
+
+ if (stockfish.is_checkmate) {
+ return 'Checkmate'
+ }
+
+ const mateEntries = Object.entries(stockfish.mate_vec ?? {})
+ const positiveMates = mateEntries.filter(([, mate]) => mate > 0)
+
+ if (positiveMates.length > 0) {
+ const minMate = positiveMates.reduce(
+ (min, [, mate]) => Math.min(min, mate),
+ Infinity,
+ )
+
+ if (isFinite(minMate)) {
+ return formatMateDisplay(minMate)
+ }
+ }
+
+ const mateVec = stockfish.mate_vec ?? {}
+ const cpEntries = Object.entries(stockfish.cp_vec)
+ const nonMateEntries = cpEntries.filter(
+ ([move]) => mateVec[move] === undefined,
+ )
+ const bestCp = nonMateEntries.reduce((acc, [, cp]) => {
+ if (acc === null) {
+ return cp
+ }
+ return isBlackTurn ? Math.min(acc, cp) : Math.max(acc, cp)
+ }, null)
+
+ if (bestCp !== null) {
+ return `${bestCp > 0 ? '+' : ''}${(bestCp / 100).toFixed(2)}`
+ }
+
+ const opponentMates = mateEntries.filter(([, mate]) => mate < 0)
+ if (opponentMates.length > 0) {
+ const maxMate = opponentMates.reduce(
+ (max, [, mate]) => Math.max(max, Math.abs(mate)),
+ 0,
+ )
+
+ if (maxMate > 0) {
+ return formatMateDisplay(-maxMate)
+ }
+ }
+
+ const fallbackCp = cpEntries.reduce((acc, [, cp]) => {
+ if (acc === null) {
+ return cp
+ }
+ return isBlackTurn ? Math.min(acc, cp) : Math.max(acc, cp)
+ }, null)
+
+ if (fallbackCp !== null) {
+ return `${fallbackCp > 0 ? '+' : ''}${(fallbackCp / 100).toFixed(2)}`
+ }
+
+ return '...'
+ }
const [tooltipData, setTooltipData] = useState<{
move: string
maiaProb?: number
stockfishCp?: number
stockfishWinrate?: number
stockfishCpRelative?: number
+ stockfishMate?: number
position: { x: number; y: number }
} | null>(null)
const [mobileTooltipMove, setMobileTooltipMove] = useState(
@@ -106,6 +200,8 @@ export const Highlight: React.FC = ({
? cpRelative
: (matchingMove as { cp_relative?: number })?.cp_relative
+ const stockfishMate = moveEvaluation?.stockfish?.mate_vec?.[move]
+
// Get Stockfish cp relative from the move evaluation if not provided
const actualStockfishCpRelative =
stockfishCpRelative !== undefined
@@ -118,6 +214,7 @@ export const Highlight: React.FC = ({
stockfishCp,
stockfishWinrate,
stockfishCpRelative: actualStockfishCpRelative,
+ stockfishMate,
position: { x: event.clientX, y: event.clientY },
})
}
@@ -164,6 +261,8 @@ export const Highlight: React.FC = ({
? cpRelative
: (matchingMove as { cp_relative?: number })?.cp_relative
+ const stockfishMate = moveEvaluation?.stockfish?.mate_vec?.[move]
+
// Get Stockfish cp relative from the move evaluation if not provided
const actualStockfishCpRelative =
stockfishCpRelative !== undefined
@@ -176,6 +275,7 @@ export const Highlight: React.FC = ({
stockfishCp,
stockfishWinrate,
stockfishCpRelative: actualStockfishCpRelative,
+ stockfishMate,
position: { x: event.clientX, y: event.clientY },
})
}
@@ -208,19 +308,34 @@ export const Highlight: React.FC = ({
})()
: false
- // Get the appropriate win rate
const getWhiteWinRate = () => {
+ if (isCurrentPositionCheckmate) {
+ const currentTurn = currentNode?.turn || 'w'
+ return currentTurn === 'w' ? '0.0%' : '100.0%'
+ }
+
+ const stockfishEval = moveEvaluation?.stockfish
+
+ if (stockfishEval?.is_checkmate) {
+ const currentTurn = currentNode?.turn || 'w'
+ return currentTurn === 'w' ? '0.0%' : '100.0%'
+ }
+
if (
- isInFirst10Ply &&
- moveEvaluation?.stockfish?.model_optimal_cp !== undefined
+ stockfishEval?.model_move &&
+ stockfishEval.mate_vec &&
+ stockfishEval.mate_vec[stockfishEval.model_move] !== undefined
) {
- // Use Stockfish win rate for first 10 ply
- const stockfishWinRate = cpToWinrate(
- moveEvaluation.stockfish.model_optimal_cp,
- )
+ const mateValue = stockfishEval.mate_vec[stockfishEval.model_move]
+ const deliveringColor =
+ mateValue > 0 ? currentTurn : currentTurn === 'w' ? 'b' : 'w'
+ return deliveringColor === 'w' ? '100.0%' : '0.0%'
+ }
+
+ if (isInFirst10Ply && stockfishEval?.model_optimal_cp !== undefined) {
+ const stockfishWinRate = cpToWinrate(stockfishEval.model_optimal_cp)
return `${Math.round(stockfishWinRate * 1000) / 10}%`
} else if (moveEvaluation?.maia) {
- // Use Maia win rate for later positions
return `${Math.round(moveEvaluation.maia.value * 1000) / 10}%`
}
return '...'
@@ -228,7 +343,6 @@ export const Highlight: React.FC = ({
useEffect(() => {
const descriptionNowExists = boardDescription?.segments?.length > 0
- // Only trigger animation when presence changes (exists vs doesn't exist)
if (hasDescriptionRef.current !== descriptionNowExists) {
hasDescriptionRef.current = descriptionNowExists
setAnimationKey((prev) => prev + 1)
@@ -238,10 +352,14 @@ export const Highlight: React.FC = ({
return (
-
-
+
+
{isHomePage ? (
@@ -252,13 +370,13 @@ export const Highlight: React.FC
= ({
setCurrentMaiaModel(e.target.value)}
- className="cursor-pointer appearance-none bg-transparent py-2 text-center text-base font-semibold text-human-1 outline-none transition-colors duration-200 hover:text-human-1/80 md:text-xxs lg:text-xs"
+ className="cursor-pointer appearance-none bg-transparent py-2 text-center text-sm font-semibold text-human-1 outline-none transition-colors duration-200 hover:text-human-1/80 md:text-xxs lg:text-xs"
>
{MAIA_MODELS.map((model) => (
Maia {model.slice(-4)}
@@ -272,23 +390,31 @@ export const Highlight: React.FC = ({
-
+
White Win %
-
+
{getWhiteWinRate()}
-
-
+
+
Human Moves
@@ -304,10 +430,14 @@ export const Highlight: React.FC
= ({
onMouseEnter={(e) => handleMouseEnter(move, 'maia', e, prob)}
onClick={(e) => handleClick(move, 'maia', e, prob)}
>
-
+
{colorSanMapping[move]?.san ?? move}
-
+
{(Math.round(prob * 1000) / 10).toFixed(1)}%
@@ -315,42 +445,55 @@ export const Highlight: React.FC = ({
})}
-
+
-
+
SF Eval{' '}
{moveEvaluation?.stockfish?.depth
? ` (d${moveEvaluation.stockfish?.depth})`
: ''}
-
- {moveEvaluation?.stockfish
- ? `${moveEvaluation.stockfish.model_optimal_cp > 0 ? '+' : ''}${moveEvaluation.stockfish.model_optimal_cp / 100}`
- : '...'}
+
+ {isCurrentPositionCheckmate
+ ? 'Checkmate'
+ : getStockfishEvalDisplay()}
-
-
+
+
Engine Moves
{recommendations.stockfish
?.slice(0, 4)
.map(({ move, cp, winrate, cp_relative }, index) => {
+ const mateValue = moveEvaluation?.stockfish?.mate_vec?.[move]
+ const moveEvalDisplay =
+ mateValue !== undefined
+ ? formatMateDisplay(mateValue)
+ : `${cp > 0 ? '+' : ''}${(cp / 100).toFixed(2)}`
return (
= ({
)
}
>
-
+
{colorSanMapping[move]?.san ?? move}
-
- {cp > 0 ? '+' : null}
- {`${(cp / 100).toFixed(2)}`}
+
+ {moveEvalDisplay}
)
@@ -395,7 +541,9 @@ export const Highlight: React.FC
= ({
-
+
{boardDescription?.segments?.length > 0 ? (
= ({
hover={hover}
makeMove={makeMove}
isHomePage={isHomePage}
+ simplified={simplified}
+ playerToMove={currentTurn}
/>
) : null}
@@ -428,6 +578,8 @@ export const Highlight: React.FC = ({
stockfishCp={tooltipData.stockfishCp}
stockfishWinrate={tooltipData.stockfishWinrate}
stockfishCpRelative={tooltipData.stockfishCpRelative}
+ stockfishMate={tooltipData.stockfishMate}
+ playerToMove={currentTurn}
position={tooltipData.position}
onClickMove={isMobile ? handleTooltipClick : undefined}
/>
diff --git a/src/components/Analysis/InteractiveDescription.tsx b/src/components/Analysis/InteractiveDescription.tsx
index 99b332b9..ac318cb5 100644
--- a/src/components/Analysis/InteractiveDescription.tsx
+++ b/src/components/Analysis/InteractiveDescription.tsx
@@ -16,6 +16,8 @@ interface Props {
hover: (move?: string) => void
makeMove: (move: string) => void
isHomePage?: boolean
+ simplified?: boolean
+ playerToMove?: 'w' | 'b'
}
export const InteractiveDescription: React.FC = ({
@@ -25,6 +27,8 @@ export const InteractiveDescription: React.FC = ({
hover,
makeMove,
isHomePage = false,
+ simplified = false,
+ playerToMove = 'w',
}) => {
const [tooltipData, setTooltipData] = useState<{
move: string
@@ -82,12 +86,10 @@ export const InteractiveDescription: React.FC = ({
return (
{renderSegments()}
-
- {/* Tooltip */}
{tooltipData && moveEvaluation && (
= ({
stockfishCpRelative={
moveEvaluation.stockfish?.cp_relative_vec[tooltipData.move]
}
+ stockfishMate={moveEvaluation.stockfish?.mate_vec?.[tooltipData.move]}
+ playerToMove={playerToMove}
position={tooltipData.position}
/>
)}
diff --git a/src/components/Analysis/LearnFromMistakes.tsx b/src/components/Analysis/LearnFromMistakes.tsx
index 0c2cf64d..5e24bfdc 100644
--- a/src/components/Analysis/LearnFromMistakes.tsx
+++ b/src/components/Analysis/LearnFromMistakes.tsx
@@ -1,9 +1,12 @@
import React from 'react'
import Image from 'next/image'
-import { LearnFromMistakesState, MistakePosition } from 'src/types/analysis'
+import {
+ LearnFromMistakesConfiguration,
+ MistakePosition,
+} from 'src/types/analysis'
interface Props {
- state: LearnFromMistakesState
+ state: LearnFromMistakesConfiguration
currentInfo: {
mistake: MistakePosition
progress: string
@@ -32,63 +35,57 @@ export const LearnFromMistakes: React.FC = ({
// Show player selection dialog
if (state.showPlayerSelection) {
return (
-
-
-
-
-
-
- school
-
-
- Learn from your mistakes
-
-
-
-
- close
-
- Stop
-
-
+
+
+
+
+ school
+
+
+ Learn from your mistakes
+
+
+
+ close
+ Stop
+
+
-
-
- Choose which player's mistakes you'd like to learn
- from:
-
-
-
onSelectPlayer('white')}
- title="Learn from White's mistakes"
- className="flex h-16 w-16 cursor-pointer items-center justify-center rounded bg-background-2 transition-colors hover:bg-human-4"
- >
-
-
-
-
-
onSelectPlayer('black')}
- title="Learn from Black's mistakes"
- className="flex h-16 w-16 cursor-pointer items-center justify-center rounded bg-background-2 transition-colors hover:bg-human-4"
- >
-
-
-
-
+
+
+ Choose which player's mistakes you'd like to learn from:
+
+
+
onSelectPlayer('white')}
+ title="Learn from White's mistakes"
+ className="flex h-16 w-16 items-center justify-center rounded border border-glass-border bg-glass transition-colors duration-200 hover:bg-glass-strong"
+ >
+
+
-
+
+
onSelectPlayer('black')}
+ title="Learn from Black's mistakes"
+ className="flex h-16 w-16 items-center justify-center rounded border border-glass-border bg-glass transition-colors duration-200 hover:bg-glass-strong"
+ >
+
+
+
+
@@ -139,84 +136,77 @@ export const LearnFromMistakes: React.FC
= ({
}
return (
-
-
-
-
-
-
- school
-
-
- Learn from your mistakes
-
- ({progress})
-
-
- close
- Stop
-
-
-
- {/* Main prompt */}
-
-
{getPromptText()}
- {getFeedbackText() && (
-
- {getFeedbackText()}
-
- )}
-
+
+
+
+
+ school
+
+
+ Learn from your mistakes
+
+ ({progress})
-
- {/* Action buttons */}
-
- {!state.showSolution && lastMoveResult !== 'correct' ? (
- <>
+
+ close
+ Stop
+
+
+
+
{getPromptText()}
+ {getFeedbackText() && (
+
+ {getFeedbackText()}
+
+ )}
+
+
+ {!state.showSolution && lastMoveResult !== 'correct' ? (
+ <>
+ {!isLastMistake && (
- lightbulb
+ skip_next
- See solution
+ Skip
- {!isLastMistake && (
-
-
- skip_next
-
- Skip
-
- )}
- >
- ) : (
+ )}
-
- {isLastMistake ? 'check' : 'arrow_forward'}
+
+ lightbulb
- {isLastMistake ? 'Finish' : 'Next mistake'}
+ See solution
- )}
-
+ >
+ ) : (
+
+
+ {isLastMistake ? 'check' : 'arrow_forward'}
+
+ {isLastMistake ? 'Finish' : 'Next mistake'}
+
+ )}
)
diff --git a/src/components/Analysis/MoveMap.tsx b/src/components/Analysis/MoveMap.tsx
index 70ed1ab9..6a7ee6da 100644
--- a/src/components/Analysis/MoveMap.tsx
+++ b/src/components/Analysis/MoveMap.tsx
@@ -27,6 +27,7 @@ interface MoveMapEntry {
winrate?: number
rawMaiaProb?: number
relativeCp?: number
+ mate?: number
}
interface Props {
@@ -35,9 +36,9 @@ interface Props {
setHoverArrow: React.Dispatch
>
makeMove: (move: string) => void
isHomePage?: boolean
+ playerToMove?: 'w' | 'b'
}
-// Helper function to convert hex color to rgba with alpha
const hexToRgba = (hex: string, alpha: number): string => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
if (!result) return hex
@@ -55,6 +56,7 @@ export const MoveMap: React.FC = ({
setHoverArrow,
makeMove,
isHomePage = false,
+ playerToMove = 'w',
}: Props) => {
const { isMobile, width } = useContext(WindowSizeContext)
const [hoveredMove, setHoveredMove] = useState(null)
@@ -250,6 +252,8 @@ export const MoveMap: React.FC = ({
stockfishCp={hoveredMoveData.rawCp}
stockfishWinrate={hoveredMoveData.winrate}
stockfishCpRelative={hoveredMoveData.relativeCp}
+ stockfishMate={hoveredMoveData.mate}
+ playerToMove={playerToMove}
position={mousePosition}
onClickMove={isMobile ? onTooltipClick : undefined}
/>
@@ -259,7 +263,7 @@ export const MoveMap: React.FC = ({
return (
@@ -330,7 +334,10 @@ export const MoveMap: React.FC = ({
tickMargin={0}
tickLine={false}
tickFormatter={(value) => `${value}%`}
- domain={([dataMin, dataMax]) => [0, dataMax > 40 ? 100 : 40]}
+ domain={([, dataMax]) => {
+ const cappedMax = dataMax > 60 ? 100 : dataMax > 40 ? 60 : 40
+ return [0, cappedMax]
+ }}
>
void
@@ -21,6 +23,8 @@ export const MoveTooltip: React.FC = ({
stockfishCp,
stockfishWinrate,
stockfishCpRelative,
+ stockfishMate,
+ playerToMove = 'w',
position,
isVisible = true,
onClickMove,
@@ -37,9 +41,23 @@ export const MoveTooltip: React.FC = ({
}
}
+ const formatMateDisplay = (mateValue: number) => {
+ const deliveringColor =
+ mateValue > 0 ? playerToMove : playerToMove === 'w' ? 'b' : 'w'
+ const prefix = deliveringColor === 'w' ? '+' : '-'
+ return `${prefix}M${Math.abs(mateValue)}`
+ }
+
+ const stockfishEvalDisplay =
+ stockfishMate !== undefined
+ ? formatMateDisplay(stockfishMate)
+ : stockfishCp !== undefined
+ ? `${stockfishCp > 0 ? '+' : ''}${(stockfishCp / 100).toFixed(2)}`
+ : undefined
+
const tooltipContent = (
= ({
aria-label={onClickMove ? `Make move ${san}` : undefined}
>
{/* Header */}
-
+
{san}
{/* Content */}
-
+
{/* Maia Probability */}
{maiaProb !== undefined && (
@@ -70,13 +88,10 @@ export const MoveTooltip: React.FC
= ({
)}
{/* Stockfish Evaluation */}
- {stockfishCp !== undefined && (
+ {stockfishEvalDisplay !== undefined && (
SF Eval:
-
- {stockfishCp > 0 ? '+' : ''}
- {(stockfishCp / 100).toFixed(2)}
-
+ {stockfishEvalDisplay}
)}
diff --git a/src/components/Analysis/MovesByRating.tsx b/src/components/Analysis/MovesByRating.tsx
index 74aece5c..753bd20c 100644
--- a/src/components/Analysis/MovesByRating.tsx
+++ b/src/components/Analysis/MovesByRating.tsx
@@ -41,7 +41,7 @@ export const MovesByRating: React.FC = ({
return (
Moves by Rating
@@ -242,7 +242,13 @@ export const MovesByRating: React.FC = ({
{
return (
-
+
{payload ? (
{payload[0]?.payload.rating}
diff --git a/src/components/Analysis/SimplifiedAnalysisOverview.tsx b/src/components/Analysis/SimplifiedAnalysisOverview.tsx
new file mode 100644
index 00000000..8dae5092
--- /dev/null
+++ b/src/components/Analysis/SimplifiedAnalysisOverview.tsx
@@ -0,0 +1,43 @@
+import React from 'react'
+
+import { Highlight } from './Highlight'
+import { SimplifiedBlunderMeter } from './SimplifiedBlunderMeter'
+
+interface SimplifiedAnalysisOverviewProps {
+ highlightProps: React.ComponentProps
+ blunderMeterProps: React.ComponentProps
+ analysisEnabled: boolean
+}
+
+export const SimplifiedAnalysisOverview: React.FC<
+ SimplifiedAnalysisOverviewProps
+> = ({ highlightProps, blunderMeterProps, analysisEnabled }) => {
+ return (
+
+ )
+}
diff --git a/src/components/Analysis/SimplifiedBlunderMeter.tsx b/src/components/Analysis/SimplifiedBlunderMeter.tsx
new file mode 100644
index 00000000..f8bd7dc8
--- /dev/null
+++ b/src/components/Analysis/SimplifiedBlunderMeter.tsx
@@ -0,0 +1,278 @@
+import React, { useContext, useEffect, useMemo, useState } from 'react'
+import { motion } from 'framer-motion'
+
+import {
+ BlunderMeterResult,
+ ColorSanMapping,
+ StockfishEvaluation,
+ MaiaEvaluation,
+} from 'src/types'
+import { WindowSizeContext } from 'src/contexts'
+import { MoveTooltip } from './MoveTooltip'
+
+interface SimplifiedBlunderMeterProps {
+ data: BlunderMeterResult
+ colorSanMapping: ColorSanMapping
+ hover: (move?: string) => void
+ makeMove: (move: string) => void
+ moveEvaluation?: {
+ maia?: MaiaEvaluation
+ stockfish?: StockfishEvaluation
+ } | null
+ playerToMove?: 'w' | 'b'
+}
+
+type CategoryKey = 'goodMoves' | 'okMoves' | 'blunderMoves'
+
+const CATEGORY_CONFIG: Record<
+ CategoryKey,
+ {
+ title: string
+ bgColor: string
+ textColor: string
+ }
+> = {
+ goodMoves: {
+ title: 'Best Moves',
+ bgColor: 'bg-[#1a9850]',
+ textColor: 'text-[#1a9850]',
+ },
+ okMoves: {
+ title: 'OK Moves',
+ bgColor: 'bg-[#fee08b]',
+ textColor: 'text-[#fee08b]',
+ },
+ blunderMoves: {
+ title: 'Blunders',
+ bgColor: 'bg-[#d73027]',
+ textColor: 'text-[#d73027]',
+ },
+}
+
+const MIN_PROBABILITY_TO_SHOW_MOVE = 8
+const MAX_MOVES_TO_SHOW = 6
+
+export const SimplifiedBlunderMeter: React.FC = ({
+ data,
+ hover,
+ makeMove,
+ colorSanMapping,
+ moveEvaluation,
+ playerToMove = 'w',
+}) => {
+ const categories = useMemo(
+ () =>
+ (['goodMoves', 'okMoves', 'blunderMoves'] as CategoryKey[]).map(
+ (key) => ({
+ key,
+ ...CATEGORY_CONFIG[key],
+ probability: Math.max(0, Math.min(100, data[key].probability)),
+ moves: data[key].moves,
+ }),
+ ),
+ [data],
+ )
+
+ const totalProbability = categories.reduce(
+ (acc, category) => acc + category.probability,
+ 0,
+ )
+
+ return (
+
+
+
+ Blunder Meter
+
+
+ {categories.map((category) => {
+ const flexGrow =
+ category.probability > 0 ? category.probability : 0.5
+ const percentage = totalProbability
+ ? Math.round((category.probability / totalProbability) * 100)
+ : 0
+
+ return (
+
+ {percentage > 8 && (
+
+ {percentage}%
+
+ )}
+
+ )
+ })}
+
+
+
+
+ {categories.map((category) => (
+
+ ))}
+
+
+ )
+}
+
+interface SimplifiedBlunderMeterColumnProps {
+ title: string
+ textColor: string
+ probability: number
+ moves: { move: string; probability: number }[]
+ hover: (move?: string) => void
+ makeMove: (move: string) => void
+ colorSanMapping: ColorSanMapping
+ moveEvaluation?: {
+ maia?: MaiaEvaluation
+ stockfish?: StockfishEvaluation
+ } | null
+ playerToMove?: 'w' | 'b'
+}
+
+const SimplifiedBlunderMeterColumn: React.FC<
+ SimplifiedBlunderMeterColumnProps
+> = ({
+ title,
+ textColor,
+ probability,
+ moves,
+ hover,
+ makeMove,
+ colorSanMapping,
+ moveEvaluation,
+ playerToMove = 'w',
+}) => {
+ const { isMobile } = useContext(WindowSizeContext)
+ const [tooltipData, setTooltipData] = useState<{
+ move: string
+ position: { x: number; y: number }
+ } | null>(null)
+ const [mobileTooltipMove, setMobileTooltipMove] = useState(
+ null,
+ )
+
+ useEffect(() => {
+ setTooltipData(null)
+ setMobileTooltipMove(null)
+ }, [colorSanMapping])
+
+ const filteredMoves = useMemo(
+ () =>
+ moves
+ .slice(0, MAX_MOVES_TO_SHOW)
+ .filter((move) => move.probability >= MIN_PROBABILITY_TO_SHOW_MOVE),
+ [moves],
+ )
+
+ const handleMouseEnter = (move: string, event: React.MouseEvent) => {
+ if (!isMobile) {
+ hover(move)
+ setTooltipData({
+ move,
+ position: { x: event.clientX, y: event.clientY },
+ })
+ }
+ }
+
+ const handleMouseLeave = () => {
+ if (!isMobile) {
+ hover()
+ setTooltipData(null)
+ }
+ }
+
+ const handleClick = (move: string, event: React.MouseEvent) => {
+ if (isMobile) {
+ if (mobileTooltipMove === move) {
+ makeMove(move)
+ setMobileTooltipMove(null)
+ setTooltipData(null)
+ } else {
+ hover(move)
+ setMobileTooltipMove(move)
+ setTooltipData({
+ move,
+ position: { x: event.clientX, y: event.clientY },
+ })
+ }
+ } else {
+ makeMove(move)
+ }
+ }
+
+ const handleTooltipClick = (move: string) => {
+ if (isMobile) {
+ makeMove(move)
+ setMobileTooltipMove(null)
+ setTooltipData(null)
+ hover()
+ }
+ }
+
+ return (
+
+
+
{title}
+
+ {Math.round(probability)}%
+
+
+
+ {filteredMoves.length ? (
+ filteredMoves.map((move) => (
+ handleMouseEnter(move.move, event)}
+ onClick={(event) => handleClick(move.move, event)}
+ >
+ {colorSanMapping[move.move]?.san || move.move}{' '}
+
+ ({Math.round(move.probability)}%)
+
+
+ ))
+ ) : (
+ No moves yet
+ )}
+
+
+ {tooltipData && moveEvaluation && (
+
+ )}
+
+ )
+}
diff --git a/src/components/Analysis/StreamAnalysis.tsx b/src/components/Analysis/StreamAnalysis.tsx
new file mode 100644
index 00000000..3e25ddaf
--- /dev/null
+++ b/src/components/Analysis/StreamAnalysis.tsx
@@ -0,0 +1,544 @@
+import React, {
+ useMemo,
+ useState,
+ useEffect,
+ useCallback,
+ useContext,
+} from 'react'
+import { motion } from 'framer-motion'
+import type { Key } from 'chessground/types'
+import { Chess, PieceSymbol } from 'chess.ts'
+import type { DrawShape } from 'chessground/draw'
+
+import { WindowSizeContext } from 'src/contexts'
+import { MAIA_MODELS } from 'src/constants/common'
+import { GameInfo } from 'src/components/Common/GameInfo'
+import { GameBoard } from 'src/components/Board/GameBoard'
+import { PlayerInfo } from 'src/components/Common/PlayerInfo'
+import { MovesContainer } from 'src/components/Board/MovesContainer'
+import { AnalyzedGame, GameNode, StreamState, ClockState } from 'src/types'
+import { BoardController } from 'src/components/Board/BoardController'
+import { PromotionOverlay } from 'src/components/Board/PromotionOverlay'
+import { AnalysisSidebar } from 'src/components/Analysis'
+import { ConfigurableScreens } from 'src/components/Analysis/ConfigurableScreens'
+import { useAnalysisController } from 'src/hooks/useAnalysisController'
+
+interface Props {
+ game: AnalyzedGame
+ streamState: StreamState
+ clockState: ClockState
+ onReconnect: () => void
+ onStopStream: () => void
+ analysisController: ReturnType
+}
+
+export const StreamAnalysis: React.FC = ({
+ game,
+ streamState,
+ clockState,
+ onReconnect,
+ onStopStream,
+ analysisController,
+}) => {
+ const { width } = useContext(WindowSizeContext)
+ const isMobile = useMemo(() => width > 0 && width <= 670, [width])
+
+ const [hoverArrow, setHoverArrow] = useState(null)
+ const [currentSquare, setCurrentSquare] = useState(null)
+ const [promotionFromTo, setPromotionFromTo] = useState<
+ [string, string] | null
+ >(null)
+
+ useEffect(() => {
+ setHoverArrow(null)
+ }, [analysisController.currentNode])
+
+ const hover = (move?: string) => {
+ if (move) {
+ setHoverArrow({
+ orig: move.slice(0, 2) as Key,
+ dest: move.slice(2, 4) as Key,
+ brush: 'green',
+ modifiers: {
+ lineWidth: 10,
+ },
+ })
+ } else {
+ setHoverArrow(null)
+ }
+ }
+
+ const makeMove = (move: string) => {
+ if (!analysisController.currentNode || !game.tree) return
+
+ const chess = new Chess(analysisController.currentNode.fen)
+ const moveAttempt = chess.move({
+ from: move.slice(0, 2),
+ to: move.slice(2, 4),
+ promotion: move[4] ? (move[4] as PieceSymbol) : undefined,
+ })
+
+ if (moveAttempt) {
+ const newFen = chess.fen()
+ const moveString =
+ moveAttempt.from +
+ moveAttempt.to +
+ (moveAttempt.promotion ? moveAttempt.promotion : '')
+ const san = moveAttempt.san
+
+ if (analysisController.currentNode.mainChild?.move === moveString) {
+ // Existing main line move - navigate to it
+ analysisController.goToNode(analysisController.currentNode.mainChild)
+ } else {
+ // For stream analysis, ALWAYS create variations for player moves
+ // This preserves the live game mainline and allows exploration
+ const newVariation = game.tree.addVariationNode(
+ analysisController.currentNode,
+ newFen,
+ moveString,
+ san,
+ analysisController.currentMaiaModel,
+ )
+ analysisController.goToNode(newVariation)
+ }
+ }
+ }
+
+ const onPlayerMakeMove = useCallback(
+ (playedMove: [string, string] | null) => {
+ if (!playedMove) return
+
+ // Check for promotions in available moves
+ const availableMoves: { from: string; to: string }[] = []
+ for (const [from, tos] of analysisController.availableMoves.entries()) {
+ for (const to of tos as string[]) {
+ availableMoves.push({ from, to })
+ }
+ }
+
+ const matching = availableMoves.filter((m) => {
+ return m.from === playedMove[0] && m.to === playedMove[1]
+ })
+
+ if (matching.length > 1) {
+ // Multiple matching moves (i.e. promotion)
+ setPromotionFromTo(playedMove)
+ return
+ }
+
+ // Single move
+ const moveUci = playedMove[0] + playedMove[1]
+ makeMove(moveUci)
+ },
+ [analysisController.availableMoves],
+ )
+
+ const onPlayerSelectPromotion = useCallback(
+ (piece: string) => {
+ if (!promotionFromTo) {
+ return
+ }
+ setPromotionFromTo(null)
+ const moveUci = promotionFromTo[0] + promotionFromTo[1] + piece
+ makeMove(moveUci)
+ },
+ [promotionFromTo, setPromotionFromTo],
+ )
+
+ const launchContinue = useCallback(() => {
+ const fen = analysisController.currentNode?.fen as string
+ const url = '/play' + '?fen=' + encodeURIComponent(fen)
+ window.open(url)
+ }, [analysisController.currentNode])
+
+ // Determine current player for promotion overlay
+ const currentPlayer = useMemo(() => {
+ if (!analysisController.currentNode) return 'white'
+ const chess = new Chess(analysisController.currentNode.fen)
+ return chess.turn() === 'w' ? 'white' : 'black'
+ }, [analysisController.currentNode])
+
+ const NestedGameInfo = () => (
+
+
+ {[game.whitePlayer, game.blackPlayer].map((player, index) => (
+
+
+
+
{player.name}
+
+ {player.rating ? <>({player.rating})> : null}
+
+
+ {game.termination?.winner === (index == 0 ? 'white' : 'black') ? (
+
1
+ ) : game.termination?.winner !== 'none' ? (
+
0
+ ) : game.termination === undefined ? (
+ <>>
+ ) : (
+
½
+ )}
+
+ ))}
+
+
+
+
+
+
{game.whitePlayer.name}
+ {game.whitePlayer.rating && (
+
({game.whitePlayer.rating})
+ )}
+
+
+ {streamState.isLive ? (
+ LIVE
+ ) : game.termination?.winner === 'none' ? (
+ ½-½
+ ) : (
+
+
+ {game.termination?.winner === 'white' ? '1' : '0'}
+
+ -
+
+ {game.termination?.winner === 'black' ? '1' : '0'}
+
+
+ )}
+
+
+
+
{game.blackPlayer.name}
+ {game.blackPlayer.rating && (
+
({game.blackPlayer.rating})
+ )}
+
+
+
+ )
+
+ const containerVariants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: {
+ duration: 0.2,
+ staggerChildren: 0.05,
+ },
+ },
+ }
+
+ const itemVariants = {
+ hidden: {
+ opacity: 0,
+ y: 4,
+ },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: {
+ duration: 0.25,
+ ease: [0.25, 0.46, 0.45, 0.94],
+ type: 'tween',
+ },
+ },
+ exit: {
+ opacity: 0,
+ y: -4,
+ transition: {
+ duration: 0.2,
+ ease: [0.25, 0.46, 0.45, 0.94],
+ type: 'tween',
+ },
+ },
+ }
+
+ const desktopLayout = (
+
+
+
+
+ {/* Header */}
+
+
+
+ {/* Moves + controller */}
+
+
+
+
+
+
+
+
{
+ const baseShapes = [...analysisController.arrows]
+ if (hoverArrow) {
+ baseShapes.push(hoverArrow)
+ }
+ return baseShapes
+ })()}
+ currentNode={analysisController.currentNode as GameNode}
+ orientation={analysisController.orientation}
+ onPlayerMakeMove={onPlayerMakeMove}
+ goToNode={analysisController.goToNode}
+ gameTree={game.tree}
+ />
+ {promotionFromTo ? (
+
+ ) : null}
+
+
+
+
+
+
{
+ // Analysis toggle not needed for stream - always enabled
+ }}
+ itemVariants={itemVariants}
+ />
+
+
+ )
+
+ const mobileLayout = (
+
+
+
+
+
+
+
+
{
+ const baseShapes = [...analysisController.arrows]
+ if (hoverArrow) {
+ baseShapes.push(hoverArrow)
+ }
+ return baseShapes
+ })()}
+ currentNode={analysisController.currentNode as GameNode}
+ orientation={analysisController.orientation}
+ onPlayerMakeMove={onPlayerMakeMove}
+ goToNode={analysisController.goToNode}
+ gameTree={game.tree}
+ />
+ {promotionFromTo ? (
+
+ ) : null}
+
+
+
+
+
+
+ )
+
+ return {isMobile ? mobileLayout : desktopLayout}
+}
diff --git a/src/components/Analysis/Tournament.tsx b/src/components/Analysis/Tournament.tsx
index 66c31779..dfacc81c 100644
--- a/src/components/Analysis/Tournament.tsx
+++ b/src/components/Analysis/Tournament.tsx
@@ -1,5 +1,5 @@
import { Dispatch, SetStateAction } from 'react'
-import { AnalysisTournamentGame } from 'src/types'
+import { WorldChampionshipGameListEntry } from 'src/types'
import { useRouter } from 'next/router'
type Props = {
id: string
@@ -11,11 +11,7 @@ type Props = {
setLoadingIndex: (index: number | null) => void
openElement: React.RefObject
selectedGameElement: React.RefObject
- analysisTournamentList: Map
- loadNewTournamentGame: (
- id: string[],
- setCurrentMove?: Dispatch>,
- ) => Promise
+ analysisTournamentList: Map
setCurrentMove?: Dispatch>
}
@@ -30,8 +26,6 @@ export const Tournament = ({
setLoadingIndex,
selectedGameElement,
analysisTournamentList,
- loadNewTournamentGame,
- setCurrentMove,
}: Props) => {
const router = useRouter()
const games = analysisTournamentList.get(id)
@@ -44,7 +38,7 @@ export const Tournament = ({
ref={openIndex == index ? openElement : null}
>
setOpenIndex(null)
@@ -62,7 +56,7 @@ export const Tournament = ({
{games?.map((game, j) => {
const selected =
@@ -73,7 +67,7 @@ export const Tournament = ({
return (
{
setLoadingIndex(j)
router.push(`/analysis/${sectionId}/${game.game_index}`)
@@ -82,7 +76,7 @@ export const Tournament = ({
>
{loadingIndex === j ? (
diff --git a/src/components/Analysis/index.ts b/src/components/Analysis/index.ts
index fc650a15..d8c8eabd 100644
--- a/src/components/Analysis/index.ts
+++ b/src/components/Analysis/index.ts
@@ -13,3 +13,7 @@ export * from './AnalysisOverlay'
export * from './InteractiveDescription'
export * from './AnalysisSidebar'
export * from './LearnFromMistakes'
+export * from './BroadcastGameList'
+export * from './BroadcastAnalysis'
+export * from './SimplifiedBlunderMeter'
+export * from './SimplifiedAnalysisOverview'
diff --git a/src/components/Board/BoardController.tsx b/src/components/Board/BoardController.tsx
index bc5c5781..0046b7e0 100644
--- a/src/components/Board/BoardController.tsx
+++ b/src/components/Board/BoardController.tsx
@@ -6,21 +6,22 @@ import { useCallback, useEffect, useMemo } from 'react'
interface Props {
orientation: 'white' | 'black'
setOrientation: (orientation: 'white' | 'black') => void
- currentNode: GameNode
+ currentNode: GameNode | null
plyCount: number
goToNode: (node: GameNode) => void
goToNextNode: () => void
goToPreviousNode: () => void
goToRootNode: () => void
- gameTree: GameTree
+ gameTree: GameTree | null
setCurrentMove?: (move: [string, string] | null) => void
disableFlip?: boolean
disablePrevious?: boolean
disableKeyboardNavigation?: boolean
disableNavigation?: boolean
+ embedded?: boolean
}
-export const BoardController: React.FC
= ({
+export const BoardController: React.FC = ({
orientation,
setOrientation,
currentNode,
@@ -35,6 +36,7 @@ export const BoardController: React.FC = ({
disablePrevious = false,
disableKeyboardNavigation = false,
disableNavigation = false,
+ embedded = false,
}: Props) => {
const { width } = useWindowSize()
@@ -43,17 +45,11 @@ export const BoardController: React.FC = ({
}, [orientation, setOrientation])
const hasPrevious = useMemo(() => {
- if (currentNode !== undefined) {
- return !!currentNode?.parent
- }
- return false
+ return !!currentNode?.parent
}, [currentNode])
const hasNext = useMemo(() => {
- if (currentNode !== undefined) {
- return !!currentNode?.mainChild
- }
- return false
+ return !!currentNode?.mainChild
}, [currentNode])
const getFirst = useCallback(() => {
@@ -72,6 +68,8 @@ export const BoardController: React.FC = ({
}, [goToNextNode, setCurrentMove])
const getLast = useCallback(() => {
+ if (!currentNode) return
+
let lastNode = currentNode
while (lastNode?.mainChild) {
lastNode = lastNode.mainChild
@@ -127,39 +125,45 @@ export const BoardController: React.FC = ({
])
return (
-
+
{FlipIcon}
‹‹‹
‹
›
›››
diff --git a/src/components/Board/GameBoard.tsx b/src/components/Board/GameBoard.tsx
index 7867e5cc..a70c0e35 100644
--- a/src/components/Board/GameBoard.tsx
+++ b/src/components/Board/GameBoard.tsx
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Chess } from 'chess.ts'
-import { chessSoundManager } from 'src/lib/chessSoundManager'
+import { useSound } from 'src/hooks/useSound'
import { defaults } from 'chessground/state'
import type { Key } from 'chessground/types'
import Chessground from '@react-chess/chessground'
@@ -35,6 +35,7 @@ export const GameBoard: React.FC
= ({
setCurrentSquare,
onSelectSquare,
}: Props) => {
+ const { playMoveSound } = useSound()
const after = useCallback(
(from: string, to: string) => {
if (onPlayerMakeMove) onPlayerMakeMove([from, to])
@@ -57,7 +58,7 @@ export const GameBoard: React.FC = ({
) {
const moveAttempt = chess.move({ from: from, to: to })
if (moveAttempt) {
- chessSoundManager.playMoveSound(isCapture)
+ playMoveSound(isCapture)
const newFen = chess.fen()
const moveString = from + to
@@ -66,7 +67,7 @@ export const GameBoard: React.FC = ({
if (currentNode.mainChild?.move === moveString) {
goToNode(currentNode.mainChild)
} else {
- const newVariation = game.tree.addVariation(
+ const newVariation = game.tree.addVariationNode(
currentNode,
newFen,
moveString,
@@ -76,13 +77,21 @@ export const GameBoard: React.FC = ({
}
}
} else {
- chessSoundManager.playMoveSound(isCapture)
+ playMoveSound(isCapture)
}
} else {
- chessSoundManager.playMoveSound(false)
+ playMoveSound(false)
}
},
- [game, gameTree, goToNode, currentNode, onPlayerMakeMove, setCurrentSquare],
+ [
+ game,
+ gameTree,
+ goToNode,
+ currentNode,
+ onPlayerMakeMove,
+ setCurrentSquare,
+ playMoveSound,
+ ],
)
const boardConfig = useMemo(() => {
diff --git a/src/components/Board/GameClock.tsx b/src/components/Board/GameClock.tsx
index c35ea39d..265d44ca 100644
--- a/src/components/Board/GameClock.tsx
+++ b/src/components/Board/GameClock.tsx
@@ -2,7 +2,7 @@ import { useState, useEffect, useContext } from 'react'
import { Color } from 'src/types'
import { AuthContext } from 'src/contexts'
-import { PlayControllerContext } from 'src/contexts/PlayControllerContext/PlayControllerContext'
+import { PlayControllerContext } from 'src/contexts/PlayControllerContext'
interface Props {
player: Color
@@ -52,7 +52,7 @@ export const GameClock: React.FC = (
return (
{props.player === 'black' ? '●' : '○'}{' '}
diff --git a/src/components/Board/GameplayInterface.tsx b/src/components/Board/GameplayInterface.tsx
index 326f45cd..917323b3 100644
--- a/src/components/Board/GameplayInterface.tsx
+++ b/src/components/Board/GameplayInterface.tsx
@@ -12,8 +12,12 @@ import Head from 'next/head'
import { useUnload } from 'src/hooks/useUnload'
import type { DrawShape } from 'chessground/draw'
import { useCallback, useContext, useMemo, useState } from 'react'
-import { AuthContext, WindowSizeContext } from 'src/contexts'
-import { PlayControllerContext } from 'src/contexts/PlayControllerContext/PlayControllerContext'
+import {
+ AuthContext,
+ WindowSizeContext,
+ TreeControllerContext,
+} from 'src/contexts'
+import { PlayControllerContext } from 'src/contexts/PlayControllerContext'
interface Props {
boardShapes?: DrawShape[]
@@ -36,6 +40,7 @@ export const GameplayInterface: React.FC
> = (
orientation,
timeControl,
currentNode,
+ setCurrentNode,
maiaVersion,
goToRootNode,
goToNextNode,
@@ -150,41 +155,48 @@ export const GameplayInterface: React.FC> = (
const desktopLayout = (
<>
-
-
-
-
- {Info}
-
-
-
-
+
+
+
> = (
/>
) : null}
-
+
{timeControl != 'unlimited' ? (
) : null}
-
-
-
-
{props.children}
-
-
+
+
+
+
+
+
+
+
{props.children}
{timeControl != 'unlimited' ? (
@@ -280,29 +291,32 @@ export const GameplayInterface: React.FC
> = (
{timeControl != 'unlimited' ? (
) : null}
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
{props.children}
-
{props.children}
> = (
Maia Chess - Play
- {layouts}
+
+ {layouts}
+
>
)
}
diff --git a/src/components/Board/MovesContainer.tsx b/src/components/Board/MovesContainer.tsx
index 0b4096e4..fafdc002 100644
--- a/src/components/Board/MovesContainer.tsx
+++ b/src/components/Board/MovesContainer.tsx
@@ -1,52 +1,23 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
-import React, { useContext, useMemo, Fragment, useEffect, useRef } from 'react'
-import { WindowSizeContext } from 'src/contexts'
-import { GameNode, AnalyzedGame, Termination, BaseGame } from 'src/types'
-import { TuringGame } from 'src/types/turing'
-import { useBaseTreeController } from 'src/hooks/useBaseTreeController'
+import { GameNode, Termination, BaseGame } from 'src/types'
+import { TreeControllerContext, WindowSizeContext } from 'src/contexts'
import { MoveClassificationIcon } from 'src/components/Common/MoveIcons'
+import React, { useContext, useMemo, Fragment, useEffect, useRef } from 'react'
-interface AnalysisProps {
- game: BaseGame | AnalyzedGame
- highlightIndices?: number[]
- termination?: Termination
- type: 'analysis'
- showAnnotations?: boolean
- showVariations?: boolean
- disableKeyboardNavigation?: boolean
- disableMoveClicking?: boolean
-}
-
-interface TuringProps {
- game: TuringGame
- highlightIndices?: number[]
- termination?: Termination
- type: 'turing'
- showAnnotations?: boolean
- showVariations?: boolean
- disableKeyboardNavigation?: boolean
- disableMoveClicking?: boolean
-}
-
-interface PlayProps {
+interface Props {
game: BaseGame
highlightIndices?: number[]
termination?: Termination
- type: 'play'
showAnnotations?: boolean
showVariations?: boolean
disableKeyboardNavigation?: boolean
disableMoveClicking?: boolean
+ startFromNode?: GameNode
+ restrictNavigationBefore?: GameNode
}
-type Props = AnalysisProps | TuringProps | PlayProps
-
-// Helper function to get move classification for display
-const getMoveClassification = (
- node: GameNode | null,
- currentMaiaModel?: string,
-) => {
+const getMoveClassification = (node: GameNode | null) => {
if (!node) {
return {
blunder: false,
@@ -64,58 +35,110 @@ const getMoveClassification = (
}
}
-export const MovesContainer: React.FC = (props) => {
+export const MovesContainer: React.FC<
+ Props & { embedded?: boolean; heightClass?: string }
+> = (props) => {
const {
game,
- highlightIndices,
termination,
- type,
+ highlightIndices,
showAnnotations = true,
showVariations = true,
disableKeyboardNavigation = false,
disableMoveClicking = false,
- } = props
+ startFromNode,
+ restrictNavigationBefore,
+ embedded = true,
+ heightClass = 'h-48',
+ } = props as Props & { embedded?: boolean; heightClass?: string }
const { isMobile } = useContext(WindowSizeContext)
const containerRef = useRef(null)
const currentMoveRef = useRef(null)
- // Helper function to determine if move indicators should be shown
const shouldShowIndicators = (node: GameNode | null) => {
if (!node || !showAnnotations) return false
- // Calculate ply from start: (moveNumber - 1) * 2 + (turn === 'b' ? 1 : 0)
const moveNumber = node.moveNumber
const turn = node.turn
const plyFromStart = (moveNumber - 1) * 2 + (turn === 'b' ? 1 : 0)
- // Only show indicators after the first 6 ply (moves 1, 2, and 3)
return plyFromStart >= 6
}
- const baseController = useBaseTreeController(type)
+ const controller = useContext(TreeControllerContext)
const mainLineNodes = useMemo(() => {
- return baseController.gameTree.getMainLine() ?? game.tree.getMainLine()
- }, [game, type, baseController.gameTree, baseController.currentNode])
+ const fullMainLine =
+ controller.gameTree?.getMainLine() ?? game.tree?.getMainLine() ?? []
+
+ if (startFromNode) {
+ // Find the index of the start node in the full main line
+ const startIndex = fullMainLine.findIndex(
+ (node) => node.fen === startFromNode.fen,
+ )
+
+ if (startIndex !== -1) {
+ // Return the main line starting from the specified node
+ const customMainLine = fullMainLine.slice(startIndex)
+ console.log('MovesContainer mainLineNodes (custom start):', {
+ fullMainLineLength: fullMainLine.length,
+ startIndex,
+ customMainLineLength: customMainLine.length,
+ startNodeFen: startFromNode.fen,
+ currentNode: controller.currentNode?.fen,
+ })
+ return customMainLine
+ } else {
+ // If start node not found in main line, build from start node
+ const customMainLine = [startFromNode]
+ let current = startFromNode.mainChild
+ while (current) {
+ customMainLine.push(current)
+ current = current.mainChild
+ }
+ console.log('MovesContainer mainLineNodes (built from start):', {
+ customMainLineLength: customMainLine.length,
+ startNodeFen: startFromNode.fen,
+ currentNode: controller.currentNode?.fen,
+ })
+ return customMainLine
+ }
+ }
+
+ console.log('MovesContainer mainLineNodes (default):', {
+ controllerTreeMainLine: controller.gameTree?.getMainLine()?.length || 0,
+ gameTreeMainLine: game?.tree?.getMainLine()?.length || 0,
+ finalMainLineLength: fullMainLine?.length || 0,
+ currentNode: controller.currentNode?.fen,
+ })
+ return fullMainLine
+ }, [game, controller.gameTree, controller.currentNode, startFromNode])
useEffect(() => {
if (disableKeyboardNavigation) return
const handleKeyDown = (event: KeyboardEvent) => {
- if (!baseController.currentNode) return
+ if (!controller.currentNode) return
switch (event.key) {
case 'ArrowRight':
event.preventDefault()
- if (baseController.currentNode.mainChild) {
- baseController.goToNode(baseController.currentNode.mainChild)
+ if (controller.currentNode.mainChild) {
+ controller.goToNode(controller.currentNode.mainChild)
}
break
case 'ArrowLeft':
event.preventDefault()
- if (baseController.currentNode.parent) {
- baseController.goToNode(baseController.currentNode.parent)
+ if (!controller.currentNode?.parent) return
+ // Treat restrictNavigationBefore as the earliest allowed node
+ if (
+ restrictNavigationBefore &&
+ controller.currentNode.fen === restrictNavigationBefore.fen
+ ) {
+ // Already at the boundary; do not go earlier
+ return
}
+ controller.goToNode(controller.currentNode.parent)
break
default:
break
@@ -125,12 +148,12 @@ export const MovesContainer: React.FC = (props) => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [
- baseController.currentNode,
- baseController.goToNode,
+ controller.currentNode,
+ controller.goToNode,
disableKeyboardNavigation,
+ restrictNavigationBefore,
])
- // Auto-scroll to current move
useEffect(() => {
if (currentMoveRef.current && containerRef.current) {
currentMoveRef.current.scrollIntoView({
@@ -139,10 +162,26 @@ export const MovesContainer: React.FC = (props) => {
inline: 'nearest',
})
}
- }, [baseController.currentNode])
+ }, [controller.currentNode])
const moves = useMemo(() => {
- const nodes = mainLineNodes.slice(1)
+ // When using startFromNode, we want to show moves AFTER that node
+ // When using default behavior, we want to skip the root node (slice(1))
+ const nodes = startFromNode
+ ? mainLineNodes.slice(1)
+ : mainLineNodes.slice(1)
+ console.log('MovesContainer moves calculation:', {
+ mainLineNodesLength: mainLineNodes.length,
+ nodesAfterSliceLength: nodes.length,
+ firstNodeTurn: nodes[0]?.turn,
+ startFromNode: startFromNode?.fen,
+ allNodes: nodes.map((n, i) => ({
+ index: i,
+ move: n?.san,
+ turn: n?.turn,
+ })),
+ })
+
const rows: (GameNode | null)[][] = []
const firstNode = nodes[0]
@@ -159,8 +198,9 @@ export const MovesContainer: React.FC = (props) => {
}, [])
}
+ console.log('MovesContainer final rows:', rows.length)
return rows
- }, [mainLineNodes])
+ }, [mainLineNodes, startFromNode])
const highlightSet = useMemo(
() => new Set(highlightIndices ?? []),
@@ -176,7 +216,10 @@ export const MovesContainer: React.FC