import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useBroadcast, useCollection, useIdentity, usePresence } from '@deplixo/sdk'; import { ChessClock } from './components/ChessClock.jsx'; import { TimeControls } from './components/TimeControls.jsx'; function App() { const identity = useIdentity(); const playerProfiles = useCollection('player-identities', { personal: true }); const gameRecords = useCollection('game-time-records', { personal: true }); const [gameConfig, setGameConfig] = useState(null); const [view, setView] = useState('setup'); const [activeSessionId, setActiveSessionId] = useState(null); const [roomId, setRoomId] = useState(''); const [connectedRoomId, setConnectedRoomId] = useState(null); const [roomMode, setRoomMode] = useState('solo'); const [roomInput, setRoomInput] = useState(''); const [roomStatus, setRoomStatus] = useState(''); const [opponentReady, setOpponentReady] = useState(false); const [peerClockSnapshot, setPeerClockSnapshot] = useState(null); const [localClockSnapshot, setLocalClockSnapshot] = useState(null); const [peerConnectionState, setPeerConnectionState] = useState('disconnected'); const [connectionToken] = useState(() => `peer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); const [moveTimingEvents, setMoveTimingEvents] = useState([]); const [shareFeedback, setShareFeedback] = useState(''); const currentIdentity = identity?.user || null; const safeIdentity = useMemo(() => { if (!currentIdentity) return null; return { userId: currentIdentity.id || currentIdentity.userId || currentIdentity.email || 'anonymous', name: currentIdentity.name || currentIdentity.displayName || currentIdentity.email || 'Guest', email: currentIdentity.email || null, avatar: currentIdentity.avatar || currentIdentity.picture || null, }; }, [currentIdentity]); const presencePayload = useMemo(() => ({ id: connectionToken, name: safeIdentity?.name || 'Guest', roomId: connectedRoomId || roomId || roomInput || null, status: connectedRoomId ? 'online' : 'offline', }), [connectionToken, connectedRoomId, roomId, roomInput, safeIdentity]); const { users } = usePresence(presencePayload); const roomPresence = useMemo(() => { if (!connectedRoomId) return []; return (users || []).filter((u) => u.roomId === connectedRoomId && u.id !== connectionToken); }, [connectedRoomId, connectionToken, users]); useEffect(() => { setOpponentReady(roomPresence.length > 0); if (connectedRoomId) { setPeerConnectionState(roomPresence.length > 0 ? 'connected' : 'connecting'); } else { setPeerConnectionState('disconnected'); } }, [connectedRoomId, roomPresence.length]); useEffect(() => { if (!connectedRoomId) { setRoomStatus(''); return; } if (peerConnectionState === 'connected') { setRoomStatus('Opponent connected and ready.'); } else if (peerConnectionState === 'connecting') { setRoomStatus('Waiting for opponent connection...'); } else { setRoomStatus('Disconnected from room.'); } }, [connectedRoomId, peerConnectionState]); const ensurePlayerProfile = useCallback(async () => { if (!safeIdentity || !playerProfiles?.add) return null; const existing = playerProfiles.items?.find( (item) => item.userId === safeIdentity.userId ); if (existing) return existing; const profile = { userId: safeIdentity.userId, name: safeIdentity.name, email: safeIdentity.email, avatar: safeIdentity.avatar, updatedAt: new Date().toISOString(), }; try { const created = await playerProfiles.add(profile); return created || profile; } catch { return profile; } }, [playerProfiles, safeIdentity]); useEffect(() => { ensurePlayerProfile(); }, [ensurePlayerProfile]); const createGameRecord = useCallback(async (config) => { const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const profile = await ensurePlayerProfile(); const selectedControl = config?.selectedControl || config?.timeControl || null; const record = { sessionId, startedAt: new Date().toISOString(), status: 'active', roomId: connectedRoomId || config?.roomId || null, config: { ...config, selectedControl, }, selectedControl, players: { white: { userId: profile?.userId || safeIdentity?.userId || 'anonymous', name: profile?.name || safeIdentity?.name || 'White', }, black: { userId: profile?.userId || safeIdentity?.userId || 'anonymous', name: profile?.name || safeIdentity?.name || 'Black', }, }, events: [{ type: 'game_started', at: new Date().toISOString(), config }], finalResult: null, clockState: config?.clockState || null, turnState: config?.turnState || null, }; try { const saved = await gameRecords.add(record); setActiveSessionId(saved?.id || sessionId); return saved || record; } catch { setActiveSessionId(sessionId); return record; } }, [connectedRoomId, ensurePlayerProfile, gameRecords, safeIdentity]); const updateGameRecord = useCallback(async (patch) => { if (!activeSessionId || !gameRecords?.update) return; const existing = gameRecords.items?.find( (item) => item.id === activeSessionId || item.sessionId === activeSessionId ); if (!existing) return; try { await gameRecords.update(existing.id || activeSessionId, { ...existing, ...patch, updatedAt: new Date().toISOString(), }); } catch { // ignore persistence errors; game continues locally } }, [activeSessionId, gameRecords]); const handleClockEvent = useCallback((event, fromPeer = false) => { if (!event) return; const timestamp = new Date().toISOString(); const normalized = { ...event, at: event.at || timestamp }; setMoveTimingEvents((prev) => { const next = [...prev, normalized]; if (next.length > 200) next.shift(); return next; }); updateGameRecord({ events: [...((gameRecords.items?.find((item) => item.id === activeSessionId || item.sessionId === activeSessionId)?.events) || []), normalized], lastEventAt: timestamp, lastEvent: normalized, ...(normalized.type === 'game_over' || normalized.type === 'flag' ? { status: 'finished', finalResult: normalized.result || normalized.type, endedAt: timestamp, } : {}), ...(normalized.clockState ? { clockState: normalized.clockState } : {}), ...(normalized.turnState ? { turnState: normalized.turnState } : {}), }); }, [activeSessionId, gameRecords.items, updateGameRecord]); const sendRoomMessage = useBroadcast( connectedRoomId ? `chess-room:${connectedRoomId}` : `chess-room:idle:${connectionToken}`, useCallback((data) => { if (!data || data.roomId !== connectedRoomId) return; if (data.type === 'room_join') { setOpponentReady(true); setPeerConnectionState('connected'); setRoomStatus(`${data.name || 'Opponent'} joined the room.`); return; } if (data.type === 'room_leave') { setOpponentReady(false); setPeerConnectionState('connecting'); setRoomStatus(`${data.name || 'Opponent'} left the room.`); return; } if (data.type === 'game_start_sync') { if (data.config) { setGameConfig((prev) => prev || data.config); setView('game'); } setActiveSessionId(data.sessionId || null); setPeerClockSnapshot(data.clockSnapshot || null); setPeerConnectionState('connected'); return; } if (data.type === 'clock_sync') { setPeerClockSnapshot(data.snapshot || null); setPeerConnectionState('connected'); return; } if (data.type === 'game_event') { handleClockEvent(data.event, true); } }, [connectedRoomId, handleClockEvent]) ); const createRoom = useCallback(() => { const nextRoom = `room-${Math.random().toString(36).slice(2, 8).toUpperCase()}`; setRoomId(nextRoom); setConnectedRoomId(nextRoom); setRoomMode('multiplayer'); setRoomStatus(`Created room ${nextRoom}. Share it with your opponent.`); setOpponentReady(false); setPeerConnectionState('connecting'); sendRoomMessage({ type: 'room_join', roomId: nextRoom, userId: safeIdentity?.userId || 'anonymous', name: safeIdentity?.name || 'Host', token: connectionToken, }); }, [connectionToken, safeIdentity, sendRoomMessage]); const joinRoom = useCallback(() => { const target = (roomInput || roomId).trim().toUpperCase(); if (!target) return; setRoomId(target); setConnectedRoomId(target); setRoomMode('multiplayer'); setRoomStatus(`Joined room ${target}. Waiting for host sync...`); setOpponentReady(false); setPeerConnectionState('connecting'); sendRoomMessage({ type: 'room_join', roomId: target, userId: safeIdentity?.userId || 'anonymous', name: safeIdentity?.name || 'Guest', token: connectionToken, }); }, [connectionToken, roomId, roomInput, safeIdentity, sendRoomMessage]); const leaveRoom = useCallback(() => { if (connectedRoomId) { sendRoomMessage({ type: 'room_leave', roomId: connectedRoomId, userId: safeIdentity?.userId || 'anonymous', name: safeIdentity?.name || 'Guest', token: connectionToken, }); } setConnectedRoomId(null); setOpponentReady(false); setPeerConnectionState('disconnected'); setRoomStatus('Left the room.'); }, [connectedRoomId, connectionToken, safeIdentity, sendRoomMessage]); const createGameRecordAndStart = useCallback(async (config) => { const nextConfig = { ...config, roomId: connectedRoomId || roomId || config?.roomId || null }; const record = await createGameRecord(nextConfig); const sessionId = record?.id || record?.sessionId || activeSessionId; setGameConfig(nextConfig); setView('game'); setPeerConnectionState(opponentReady ? 'connected' : 'connecting'); sendRoomMessage({ type: 'game_start_sync', roomId: connectedRoomId, sessionId, config: nextConfig, clockSnapshot: localClockSnapshot || null, }); }, [activeSessionId, connectedRoomId, createGameRecord, localClockSnapshot, opponentReady, roomId, sendRoomMessage]); const handleStartGame = async (config) => { await createGameRecordAndStart(config); }; const startMultiplayerGame = useCallback(async (config) => { await createGameRecordAndStart(config); }, [createGameRecordAndStart]); useEffect(() => { if (connectedRoomId && view === 'game' && gameConfig) { sendRoomMessage({ type: 'clock_sync', roomId: connectedRoomId, snapshot: localClockSnapshot, }); } }, [connectedRoomId, gameConfig, localClockSnapshot, sendRoomMessage, view]); const handleBackToSetup = () => { leaveRoom(); setGameConfig(null); setActiveSessionId(null); setView('setup'); }; const initialPlayers = useMemo(() => ({ white: { userId: safeIdentity?.userId || 'anonymous', name: safeIdentity?.name || 'White', }, black: { userId: safeIdentity?.userId || 'anonymous', name: safeIdentity?.name || 'Black', }, }), [safeIdentity]); const moveAnalytics = useMemo(() => { const moves = (moveTimingEvents || []).filter((event) => event && (event.type === 'move' || event.type === 'turn_switch' || event.type === 'turn_change')); const players = { white: { times: [], rolling: 0, average: 0, count: 0 }, black: { times: [], rolling: 0, average: 0, count: 0 }, }; let lastByPlayer = { white: null, black: null }; let rollingWindow = []; for (const event of moves) { const player = (event.player || event.side || event.turn || event.color || '').toString().toLowerCase(); const normalizedPlayer = player === 'black' ? 'black' : 'white'; const at = event.at ? new Date(event.at).getTime() : NaN; if (!Number.isFinite(at)) continue; if (lastByPlayer[normalizedPlayer] != null) { const delta = Math.max(0, at - lastByPlayer[normalizedPlayer]); players[normalizedPlayer].times.push(delta); rollingWindow.push(delta); if (rollingWindow.length > 5) rollingWindow.shift(); } lastByPlayer[normalizedPlayer] = at; } for (const key of ['white', 'black']) { const times = players[key].times; const total = times.reduce((sum, n) => sum + n, 0); players[key].count = times.length; players[key].average = times.length ? total / times.length : 0; const lastFive = times.slice(-5); players[key].rolling = lastFive.length ? lastFive.reduce((sum, n) => sum + n, 0) / lastFive.length : 0; } const maxValue = Math.max( players.white.average, players.black.average, players.white.rolling, players.black.rolling, 1 ); return { white: players.white, black: players.black, maxValue, }; }, [moveTimingEvents]); const avgMoveChartRef = useRef(null); useEffect(() => { if (!avgMoveChartRef.current) return; const canvas = avgMoveChartRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const width = canvas.clientWidth || 520; const height = canvas.clientHeight || 220; canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, width, height); const pad = { top: 20, right: 16, bottom: 34, left: 44 }; const chartW = width - pad.left - pad.right; const chartH = height - pad.top - pad.bottom; const barW = chartW / 4; const data = [ { label: 'White avg', value: moveAnalytics.white.average, color: '#e43f5a' }, { label: 'Black avg', value: moveAnalytics.black.average, color: '#2ecc71' }, { label: 'White 5', value: moveAnalytics.white.rolling, color: '#ff7a90' }, { label: 'Black 5', value: moveAnalytics.black.rolling, color: '#6ee7a8' }, ]; const maxValue = moveAnalytics.maxValue || 1; ctx.fillStyle = 'rgba(255,255,255,0.06)'; ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; for (let i = 0; i <= 4; i += 1) { const y = pad.top + (chartH * i) / 4; ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(width - pad.right, y); ctx.stroke(); } data.forEach((item, index) => { const x = pad.left + index * barW + barW * 0.15; const h = (item.value / maxValue) * chartH; const y = pad.top + chartH - h; const bw = barW * 0.7; ctx.fillStyle = item.color; ctx.fillRect(x, y, bw, h); ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.font = '12px Plus Jakarta Sans, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(`${Math.round(item.value)} ms`, x + bw / 2, y - 6); ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText(item.label, x + bw / 2, height - 12); }); }, [moveAnalytics]); const selectedControlLabel = useMemo(() => { const control = gameConfig?.selectedControl || gameConfig?.timeControl || gameConfig?.preset || null; if (!control) return 'Custom'; if (typeof control === 'string') return control; if (typeof control === 'object') { const minutes = control.minutes ?? control.baseMinutes ?? control.base ?? null; const increment = control.increment ?? control.incrementSeconds ?? control.inc ?? null; if (minutes != null && increment != null) return `${minutes}+${increment}`; if (minutes != null) return `${minutes} min`; } return 'Time Control'; }, [gameConfig]); const currentClockSummary = useMemo(() => { const sessionRecord = gameRecords.items?.find((item) => item.id === activeSessionId || item.sessionId === activeSessionId) || null; const finalResult = sessionRecord?.finalResult || sessionRecord?.status || 'finished'; const whiteMs = moveAnalytics.white.average || 0; const blackMs = moveAnalytics.black.average || 0; const whiteRoll = moveAnalytics.white.rolling || 0; const blackRoll = moveAnalytics.black.rolling || 0; return { finalResult, whiteMs, blackMs, whiteRoll, blackRoll, sessionRecord, }; }, [activeSessionId, gameRecords.items, moveAnalytics.black.average, moveAnalytics.black.rolling, moveAnalytics.white.average, moveAnalytics.white.rolling]); const shareResult = useCallback(async () => { const sessionRecord = currentClockSummary.sessionRecord; const roomPart = connectedRoomId ? `Room ${connectedRoomId}` : 'Solo game'; const scorePart = sessionRecord?.finalResult ? `Result: ${sessionRecord.finalResult}` : 'Result: game finished'; const statsPart = `Avg move times — White: ${Math.round(currentClockSummary.whiteMs)} ms (5: ${Math.round(currentClockSummary.whiteRoll)} ms), Black: ${Math.round(currentClockSummary.blackMs)} ms (5: ${Math.round(currentClockSummary.blackRoll)} ms)`; const controlPart = `Time control: ${selectedControlLabel}`; const url = typeof window !== 'undefined' ? window.location.href : ''; const text = [ 'Chess Clock result', roomPart, controlPart, scorePart, statsPart, activeSessionId ? `Session: ${activeSessionId}` : null, url ? `Link: ${url}` : null, ].filter(Boolean).join('\n'); try { if (navigator.share) { await navigator.share({ title: 'Chess Clock result', text, url: url || undefined, }); setShareFeedback('Shared successfully.'); return; } } catch { } try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); setShareFeedback('Summary copied to clipboard.'); return; } } catch { } try { const textarea = document.createElement('textarea'); textarea.value = text; textarea.setAttribute('readonly', 'true'); textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); setShareFeedback('Summary copied to clipboard.'); } catch { setShareFeedback('Sharing is not available in this browser.'); } }, [activeSessionId, connectedRoomId, currentClockSummary.blackMs, currentClockSummary.blackRoll, currentClockSummary.sessionRecord, currentClockSummary.whiteMs, currentClockSummary.whiteRoll, selectedControlLabel]); useEffect(() => { if (!shareFeedback) return undefined; const timer = setTimeout(() => setShareFeedback(''), 2500); return () => clearTimeout(timer); }, [shareFeedback]); return (
{view === 'setup' && (
Chess Clock
Choose a time control or start a room for multiplayer play.

Online Rooms

{peerConnectionState}
setRoomInput(e.target.value)} placeholder="Enter room code" />
{connectedRoomId ? `Connected: ${connectedRoomId}` : 'Not connected'} {opponentReady ? 'Opponent online' : 'Waiting for opponent'}
{roomStatus &&
{roomStatus}
} {connectedRoomId && ( )}
)} {view === 'game' && gameConfig && (

Average Move Time

Overall and rolling 5-move averages by player.

White {Math.round(moveAnalytics.white.average || 0)} ms Rolling: {Math.round(moveAnalytics.white.rolling || 0)} ms
Black {Math.round(moveAnalytics.black.average || 0)} ms Rolling: {Math.round(moveAnalytics.black.rolling || 0)} ms

Share result

Copy or share a summary with final result, room, and timing stats.

{shareFeedback &&
{shareFeedback}
}
)}
); } export default App; ReactDOM.createRoot(document.getElementById("root")).render();