From 8085d2ecc8e4dabd44c275297dbe7804049ccf8e Mon Sep 17 00:00:00 2001 From: Frank Schwenk Date: Thu, 30 Oct 2025 09:36:17 +0100 Subject: [PATCH] feat(storage): migrate to IndexedDB with localStorage fallback and async app flow - Add IndexedDB service with schema, indexes, and player stats - Migrate GameService to async IndexedDB and auto-migrate from localStorage - Update hooks and App handlers to async; add error handling and UX feedback - Convert remaining JSX components to TSX - Add test utility for IndexedDB and migration checks - Extend game types with sync fields for future online sync --- src/components/App.tsx | 168 ++++++--- ...lscreenToggle.jsx => FullscreenToggle.tsx} | 0 ...etionModal.jsx => GameCompletionModal.tsx} | 17 +- .../{GameDetail.jsx => GameDetail.tsx} | 47 ++- src/components/GameList.tsx | 19 +- src/components/{Modal.jsx => Modal.tsx} | 17 +- src/components/{NewGame.jsx => NewGame.tsx} | 246 ++++++++---- src/components/PlayerInputStep.tsx | 1 + src/components/{Toast.jsx => Toast.tsx} | 19 +- ...alidationModal.jsx => ValidationModal.tsx} | 13 +- src/components/ui/Layout.tsx | 5 +- src/hooks/useGameState.ts | 106 ++++-- src/services/gameService.ts | 196 ++++++++-- src/services/indexedDBService.ts | 353 ++++++++++++++++++ src/styles/index.css | 36 ++ src/types/game.ts | 18 +- src/types/ui.ts | 12 +- src/utils/constants.ts | 85 +++++ src/utils/testIndexedDB.ts | 140 +++++++ src/utils/validation.ts | 67 ++-- 20 files changed, 1288 insertions(+), 277 deletions(-) rename src/components/{FullscreenToggle.jsx => FullscreenToggle.tsx} (100%) rename src/components/{GameCompletionModal.jsx => GameCompletionModal.tsx} (90%) rename src/components/{GameDetail.jsx => GameDetail.tsx} (74%) rename src/components/{Modal.jsx => Modal.tsx} (78%) rename src/components/{NewGame.jsx => NewGame.tsx} (76%) create mode 100644 src/components/PlayerInputStep.tsx rename src/components/{Toast.jsx => Toast.tsx} (82%) rename src/components/{ValidationModal.jsx => ValidationModal.tsx} (81%) create mode 100644 src/services/indexedDBService.ts create mode 100644 src/utils/testIndexedDB.ts diff --git a/src/components/App.tsx b/src/components/App.tsx index 54decdd..59e63be 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,12 +1,12 @@ import { h } from 'preact'; -import { useEffect } from 'preact/hooks'; +import { useEffect, useCallback } from 'preact/hooks'; import { useGameState } from '../hooks/useGameState'; import { useNavigation, useNewGameWizard } from '../hooks/useNavigation'; import { useModal, useValidationModal, useCompletionModal } from '../hooks/useModal'; import { GameService } from '../services/gameService'; -import type { StandardGame } from '../types/game'; +import type { StandardGame, Game, EndlosGame } from '../types/game'; import { Layout } from './ui/Layout'; import GameListScreen from './screens/GameListScreen'; @@ -30,72 +30,92 @@ export default function App() { const completionModal = useCompletionModal(); // Game lifecycle handlers - const handleCreateGame = (gameData: any) => { - const gameId = gameState.addGame(gameData); - newGameWizard.resetWizard(); - navigation.showGameDetail(gameId); - }; + const handleCreateGame = useCallback(async (gameData: any) => { + try { + const gameId = await gameState.addGame(gameData); + newGameWizard.resetWizard(); + navigation.showGameDetail(gameId); + } catch (error) { + console.error('Failed to create game:', error); + validationModal.showValidation('Failed to create game. Please try again.'); + } + }, [gameState.addGame, newGameWizard.resetWizard, navigation.showGameDetail, validationModal.showValidation]); - const handleUpdateScore = (player: number, change: number) => { + const handleUpdateScore = useCallback(async (player: number, change: number) => { if (!navigation.currentGameId) return; const game = gameState.getGameById(navigation.currentGameId); if (!game || game.status === 'completed') return; - const updatedGame = GameService.updateGameScore(game as StandardGame, player, change); - - // Add undo state for standard games - const gameWithHistory = { - ...updatedGame, - undoStack: [...(game.undoStack || []), game], - updatedAt: new Date().toISOString(), - }; - - gameState.updateGame(navigation.currentGameId, gameWithHistory); - - // Check for completion - if (GameService.isGameCompleted(gameWithHistory)) { - completionModal.openCompletionModal(gameWithHistory); + try { + const updatedGame = GameService.updateGameScore(game as StandardGame, player, change); + + // Add undo state for standard games + const gameWithHistory = { + ...updatedGame, + undoStack: [...(game.undoStack || []), game], + updatedAt: new Date().toISOString(), + }; + + await gameState.updateGame(navigation.currentGameId, gameWithHistory); + + // Check for completion + if (GameService.isGameCompleted(gameWithHistory)) { + completionModal.openCompletionModal(gameWithHistory); + } + } catch (error) { + console.error('Failed to update score:', error); + validationModal.showValidation('Failed to update score. Please try again.'); } - }; + }, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, completionModal.openCompletionModal, validationModal.showValidation]); - const handleUndo = () => { + const handleUndo = useCallback(async () => { if (!navigation.currentGameId) return; const game = gameState.getGameById(navigation.currentGameId); if (!game?.undoStack?.length) return; - const lastState = game.undoStack[game.undoStack.length - 1]; - const newUndoStack = game.undoStack.slice(0, -1); + try { + const lastState = game.undoStack[game.undoStack.length - 1]; + const newUndoStack = game.undoStack.slice(0, -1); - gameState.updateGame(navigation.currentGameId, { - ...lastState, - undoStack: newUndoStack, - }); - }; + await gameState.updateGame(navigation.currentGameId, { + ...lastState, + undoStack: newUndoStack, + } as Game); + } catch (error) { + console.error('Failed to undo:', error); + validationModal.showValidation('Failed to undo. Please try again.'); + } + }, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, validationModal.showValidation]); - const handleForfeit = () => { + const handleForfeit = async () => { if (!navigation.currentGameId) return; const game = gameState.getGameById(navigation.currentGameId); if (!game || !('players' in game)) return; - const currentPlayerIndex = game.currentPlayer; - if (currentPlayerIndex === null) return; + try { + const currentPlayerIndex = game.currentPlayer; + if (currentPlayerIndex === null) return; - const winner = game.players.find((_, idx) => idx !== currentPlayerIndex); - const forfeitedGame = { - ...game, - status: 'completed' as const, - winner: winner?.name, - forfeitedBy: game.players[currentPlayerIndex].name, - updatedAt: new Date().toISOString(), - }; - - gameState.updateGame(navigation.currentGameId, forfeitedGame); - completionModal.openCompletionModal(forfeitedGame); + const winner = game.players.find((_, idx) => idx !== currentPlayerIndex); + const forfeitedGame = { + ...game, + status: 'completed' as const, + winner: winner?.name, + forfeitedBy: game.players[currentPlayerIndex].name, + updatedAt: new Date().toISOString(), + }; + + await gameState.updateGame(navigation.currentGameId, forfeitedGame); + completionModal.openCompletionModal(forfeitedGame); + } catch (error) { + console.error('Failed to forfeit game:', error); + validationModal.showValidation('Failed to forfeit game. Please try again.'); + } }; const handleFinishGame = () => { @@ -107,19 +127,24 @@ export default function App() { completionModal.openCompletionModal(game); }; - const handleConfirmCompletion = () => { + const handleConfirmCompletion = async () => { if (!navigation.currentGameId) return; - gameState.updateGame(navigation.currentGameId, { - ...gameState.getGameById(navigation.currentGameId)!, - status: 'completed', - updatedAt: new Date().toISOString(), - }); - - completionModal.closeCompletionModal(); + try { + await gameState.updateGame(navigation.currentGameId, { + ...gameState.getGameById(navigation.currentGameId)!, + status: 'completed', + updatedAt: new Date().toISOString(), + }); + + completionModal.closeCompletionModal(); + } catch (error) { + console.error('Failed to complete game:', error); + validationModal.showValidation('Failed to complete game. Please try again.'); + } }; - const handleRematch = () => { + const handleRematch = async () => { if (!navigation.currentGameId) return; const completedGame = gameState.getGameById(navigation.currentGameId); @@ -144,28 +169,38 @@ export default function App() { }; } - const newGameId = gameState.addGame(gameData); - completionModal.closeCompletionModal(); - navigation.showGameDetail(newGameId); + try { + const newGameId = await gameState.addGame(gameData); + completionModal.closeCompletionModal(); + navigation.showGameDetail(newGameId); + } catch (error) { + console.error('Failed to create rematch:', error); + validationModal.showValidation('Failed to create rematch. Please try again.'); + } }; const handleDeleteGame = (gameId: number) => { modal.openModal(gameId); }; - const handleConfirmDelete = () => { + const handleConfirmDelete = async () => { if (modal.modal.gameId) { - gameState.deleteGame(modal.modal.gameId); + try { + await gameState.deleteGame(modal.modal.gameId); + modal.closeModal(); + navigation.showGameList(); + } catch (error) { + console.error('Failed to delete game:', error); + validationModal.showValidation('Failed to delete game. Please try again.'); + } } - modal.closeModal(); - navigation.showGameList(); }; return ( {navigation.screen === 'game-list' && ( { + try { + await gameState.updateGame(navigation.currentGameId!, game); + } catch (error) { + console.error('Failed to update game:', error); + validationModal.showValidation('Failed to update game. Please try again.'); + } + }} onUndo={handleUndo} + onForfeit={handleForfeit} onBack={navigation.showGameList} /> )} diff --git a/src/components/FullscreenToggle.jsx b/src/components/FullscreenToggle.tsx similarity index 100% rename from src/components/FullscreenToggle.jsx rename to src/components/FullscreenToggle.tsx diff --git a/src/components/GameCompletionModal.jsx b/src/components/GameCompletionModal.tsx similarity index 90% rename from src/components/GameCompletionModal.jsx rename to src/components/GameCompletionModal.tsx index 95a0e45..3d463bb 100644 --- a/src/components/GameCompletionModal.jsx +++ b/src/components/GameCompletionModal.tsx @@ -1,19 +1,20 @@ import { h } from 'preact'; import modalStyles from './Modal.module.css'; import styles from './GameCompletionModal.module.css'; +import type { Game } from '../types/game'; +interface GameCompletionModalProps { + open: boolean; + game: Game | null; + onConfirm: () => void; + onClose: () => void; + onRematch: () => void; +} /** * Modal shown when a game is completed. - * @param {object} props - * @param {boolean} props.open - * @param {object} props.game - * @param {Function} props.onConfirm - * @param {Function} props.onClose - * @param {Function} props.onRematch - * @returns {import('preact').VNode|null} */ -const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }) => { +const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }: GameCompletionModalProps) => { if (!open || !game) return null; const playerNames = [game.player1, game.player2, game.player3].filter(Boolean); diff --git a/src/components/GameDetail.jsx b/src/components/GameDetail.tsx similarity index 74% rename from src/components/GameDetail.jsx rename to src/components/GameDetail.tsx index 9151534..3e22175 100644 --- a/src/components/GameDetail.jsx +++ b/src/components/GameDetail.tsx @@ -1,30 +1,36 @@ import { h } from 'preact'; import { useState } from 'preact/hooks'; import styles from './GameDetail.module.css'; -import Toast from './Toast.jsx'; +import Toast from './Toast'; +import type { Game, EndlosGame } from '../types/game'; + +interface GameDetailProps { + game: Game | undefined; + onFinishGame: () => void; + onUpdateScore: (player: number, change: number) => void; + onUpdateGame?: (game: EndlosGame) => void; + onUndo?: () => void; + onForfeit?: () => void; + onBack: () => void; +} /** * Game detail view for a single game. - * @param {object} props - * @param {object} props.game - * @param {Function} props.onFinishGame - * @param {Function} props.onUpdateScore - * @param {Function} props.onUpdateGame - * @param {Function} props.onUndo - * @param {Function} props.onForfeit - * @param {Function} props.onBack - * @returns {import('preact').VNode|null} */ -const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }) => { - const [toast, setToast] = useState({ show: false, message: '', type: 'info' }); +const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }: GameDetailProps) => { + const [toast, setToast] = useState<{ show: boolean; message: string; type: 'success' | 'error' | 'info' }>({ + show: false, + message: '', + type: 'info' + }); if (!game) return null; - const showToast = (message, type = 'info') => { + const showToast = (message: string, type: 'success' | 'error' | 'info' = 'info') => { setToast({ show: true, message, type }); }; - const handleScoreUpdate = (playerIndex, change) => { + const handleScoreUpdate = (playerIndex: number, change: number) => { onUpdateScore(playerIndex, change); const playerName = [game.player1, game.player2, game.player3][playerIndex - 1]; const action = change > 0 ? 'Punkt hinzugefügt' : 'Punkt abgezogen'; @@ -33,6 +39,7 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o const isCompleted = game.status === 'completed'; + const playerNames = [game.player1, game.player2, game.player3].filter(Boolean); const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]); @@ -62,7 +69,15 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o className={styles['score']} id={`score${idx + 1}`} onClick={() => !isCompleted && onUpdateScore(idx + 1, 1)} - aria-label={`Aktueller Punktestand für ${name}: ${scores[idx]}. Klicken zum Erhöhen.`} + onKeyDown={(e) => { + if (!isCompleted && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onUpdateScore(idx + 1, 1); + } + }} + role="button" + tabIndex={isCompleted ? -1 : 0} + aria-label={`Aktueller Punktestand für ${name}: ${scores[idx]}. Klicken oder Enter drücken zum Erhöhen.`} > {scores[idx]} @@ -72,12 +87,14 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o disabled={isCompleted} onClick={() => handleScoreUpdate(idx+1, -1)} aria-label={`Punkt abziehen für ${name}`} + title={`Punkt abziehen für ${name}`} >- diff --git a/src/components/GameList.tsx b/src/components/GameList.tsx index b2eff3e..8816c6d 100644 --- a/src/components/GameList.tsx +++ b/src/components/GameList.tsx @@ -49,10 +49,10 @@ export default function GameList({ } }; - const filterButtons: Array<{ key: GameFilter; label: string; ariaLabel: string }> = [ - { key: 'all', label: 'Alle', ariaLabel: 'Alle Spiele anzeigen' }, - { key: 'active', label: 'Aktiv', ariaLabel: 'Nur aktive Spiele anzeigen' }, - { key: 'completed', label: 'Abgeschlossen', ariaLabel: 'Nur abgeschlossene Spiele anzeigen' }, + const filterButtons = [ + { key: 'all' as const, label: 'Alle', ariaLabel: 'Alle Spiele anzeigen' }, + { key: 'active' as const, label: 'Aktiv', ariaLabel: 'Nur aktive Spiele anzeigen' }, + { key: 'completed' as const, label: 'Abgeschlossen', ariaLabel: 'Nur abgeschlossene Spiele anzeigen' }, ]; return ( @@ -87,15 +87,26 @@ export default function GameList({
onShowGameDetail(game.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onShowGameDetail(game.id); + } + }} role="button" tabIndex={0} aria-label={`Details für Spiel ${playerNames}`} + aria-describedby={`game-${game.id}-description`} >
{game.gameType}{game.raceTo ? ` | ${game.raceTo}` : ''}
{playerNames}
{scores}
+
+ {game.gameType} Spiel zwischen {playerNames} mit dem Stand {scores}. + {game.status === 'completed' ? 'Abgeschlossen' : 'Aktiv'} +
))} - {playerNameHistory.length > 10 && ( + {playerNameHistory.length > UI_CONSTANTS.MAX_QUICK_PICKS && ( @@ -153,7 +241,20 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) )} - {error &&
{error}
} + {error && ( +
+ ⚠️ + {error} +
+ )} {isModalOpen && ( { +const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => { const [player2, setPlayer2] = useState(initialValue); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [filteredNames, setFilteredNames] = useState(playerNameHistory); - const inputRef = useRef(null); + const inputRef = useRef(null); useEffect(() => { if (!player2) { @@ -213,7 +308,7 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) } }, [player2, playerNameHistory]); - const handleSubmit = (e) => { + const handleSubmit = (e: Event) => { e.preventDefault(); if (!player2.trim()) { setError('Bitte Namen für Spieler 2 eingeben'); @@ -223,7 +318,7 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) onNext(player2.trim()); }; - const handleQuickPick = (name) => { + const handleQuickPick = (name: string) => { setError(null); onNext(name); }; @@ -252,7 +347,10 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) className={styles['name-input']} placeholder="Name Spieler 2" value={player2} - onInput={e => setPlayer2(e.target.value)} + onInput={(e: Event) => { + const target = e.target as HTMLInputElement; + setPlayer2(target.value); + }} autoComplete="off" aria-label="Name Spieler 2" style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }} @@ -326,17 +424,11 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) /** * Player 3 input step for multi-step game creation wizard. - * @param {object} props - * @param {string[]} props.playerNameHistory - * @param {Function} props.onNext - * @param {Function} props.onCancel - * @param {string} [props.initialValue] - * @returns {import('preact').VNode} */ -const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) => { +const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => { const [player3, setPlayer3] = useState(initialValue); const [filteredNames, setFilteredNames] = useState(playerNameHistory); - const inputRef = useRef(null); + const inputRef = useRef(null); useEffect(() => { if (!player3) { @@ -350,13 +442,13 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) } }, [player3, playerNameHistory]); - const handleSubmit = (e) => { + const handleSubmit = (e: Event) => { e.preventDefault(); // Player 3 is optional, so always allow submission onNext(player3.trim()); }; - const handleQuickPick = (name) => { + const handleQuickPick = (name: string) => { onNext(name); }; @@ -365,7 +457,7 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) if (inputRef.current) inputRef.current.focus(); }; - const handleSkip = (e) => { + const handleSkip = (e: Event) => { e.preventDefault(); onNext(''); }; @@ -388,7 +480,10 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) className={styles['name-input']} placeholder="Name Spieler 3 (optional)" value={player3} - onInput={e => setPlayer3(e.target.value)} + onInput={(e: Event) => { + const target = e.target as HTMLInputElement; + setPlayer3(target.value); + }} autoComplete="off" aria-label="Name Spieler 3" style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }} @@ -469,23 +564,24 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) ); }; +interface GameTypeStepProps { + onNext: (type: string) => void; + onCancel: () => void; + initialValue?: string; +} + /** * Game Type selection step for multi-step game creation wizard. - * @param {object} props - * @param {Function} props.onNext - * @param {Function} props.onCancel - * @param {string} [props.initialValue] - * @returns {import('preact').VNode} */ -const GameTypeStep = ({ onNext, onCancel, initialValue = '' }) => { +const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeStepProps) => { const [gameType, setGameType] = useState(initialValue); const gameTypes = ['8-Ball', '9-Ball', '10-Ball']; - const handleSelect = (selectedType) => { + const handleSelect = (selectedType: string) => { setGameType(selectedType); }; - const handleSubmit = (e) => { + const handleSubmit = (e: Event) => { e.preventDefault(); if (gameType) { onNext(gameType); @@ -551,19 +647,20 @@ const GameTypeStep = ({ onNext, onCancel, initialValue = '' }) => { ); }; +interface RaceToStepProps { + onNext: (raceTo: string | number) => void; + onCancel: () => void; + initialValue?: string | number; + gameType?: string; +} + /** * Race To selection step for multi-step game creation wizard. - * @param {object} props - * @param {Function} props.onNext - * @param {Function} props.onCancel - * @param {string|number} [props.initialValue] - * @param {string} [props.gameType] - * @returns {import('preact').VNode} */ -const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }) => { +const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: RaceToStepProps) => { const quickPicks = [1, 2, 3, 4, 5, 6, 7, 8, 9]; const defaultValue = 5; - const [raceTo, setRaceTo] = useState(initialValue !== '' ? initialValue : defaultValue); + const [raceTo, setRaceTo] = useState(initialValue !== '' ? initialValue : defaultValue); useEffect(() => { if ((initialValue === '' || initialValue === undefined) && raceTo !== defaultValue) { @@ -574,19 +671,20 @@ const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }) => { } }, [gameType, initialValue, defaultValue]); - const handleQuickPick = (value) => { + const handleQuickPick = (value: number) => { // For endlos (endless) games, use Infinity to prevent automatic completion setRaceTo(value === 0 ? 'Infinity' : value); }; - const handleInputChange = (e) => { - setRaceTo(e.target.value); + const handleInputChange = (e: Event) => { + const target = e.target as HTMLInputElement; + setRaceTo(target.value); }; - const handleSubmit = (e) => { + const handleSubmit = (e: Event) => { e.preventDefault(); // Handle Infinity for endlos games, otherwise parse as integer - const raceToValue = raceTo === 'Infinity' ? Infinity : (parseInt(raceTo, 10) || 0); + const raceToValue = raceTo === 'Infinity' ? Infinity : (parseInt(String(raceTo), 10) || 0); onNext(raceToValue); }; diff --git a/src/components/PlayerInputStep.tsx b/src/components/PlayerInputStep.tsx new file mode 100644 index 0000000..b324c6a --- /dev/null +++ b/src/components/PlayerInputStep.tsx @@ -0,0 +1 @@ +import { h } from "preact"; diff --git a/src/components/Toast.jsx b/src/components/Toast.tsx similarity index 82% rename from src/components/Toast.jsx rename to src/components/Toast.tsx index 4b2ddd1..4549e43 100644 --- a/src/components/Toast.jsx +++ b/src/components/Toast.tsx @@ -2,17 +2,20 @@ import { h } from 'preact'; import { useEffect, useState } from 'preact/hooks'; import styles from './Toast.module.css'; +type ToastType = 'success' | 'error' | 'info'; + +interface ToastProps { + show: boolean; + message: string; + type?: ToastType; + onClose: () => void; + duration?: number; +} + /** * Toast notification component for user feedback - * @param {object} props - * @param {boolean} props.show - * @param {string} props.message - * @param {string} props.type - 'success', 'error', 'info' - * @param {Function} props.onClose - * @param {number} [props.duration=3000] - * @returns {import('preact').VNode|null} */ -const Toast = ({ show, message, type = 'info', onClose, duration = 3000 }) => { +const Toast = ({ show, message, type = 'info', onClose, duration = 3000 }: ToastProps) => { const [isVisible, setIsVisible] = useState(false); useEffect(() => { diff --git a/src/components/ValidationModal.jsx b/src/components/ValidationModal.tsx similarity index 81% rename from src/components/ValidationModal.jsx rename to src/components/ValidationModal.tsx index c48959f..3c76b0b 100644 --- a/src/components/ValidationModal.jsx +++ b/src/components/ValidationModal.tsx @@ -1,15 +1,16 @@ import { h } from 'preact'; import styles from './Modal.module.css'; +interface ValidationModalProps { + open: boolean; + message: string; + onClose: () => void; +} + /** * Modal for displaying validation errors. - * @param {object} props - * @param {boolean} props.open - * @param {string} props.message - * @param {Function} props.onClose - * @returns {import('preact').VNode|null} */ -const ValidationModal = ({ open, message, onClose }) => { +const ValidationModal = ({ open, message, onClose }: ValidationModalProps) => { if (!open) return null; return (
diff --git a/src/components/ui/Layout.tsx b/src/components/ui/Layout.tsx index f8841ef..9555764 100644 --- a/src/components/ui/Layout.tsx +++ b/src/components/ui/Layout.tsx @@ -9,7 +9,10 @@ interface LayoutProps { export function Layout({ children, className = '' }: LayoutProps) { return (
- diff --git a/src/hooks/useGameState.ts b/src/hooks/useGameState.ts index effdf94..ec5bbd4 100644 --- a/src/hooks/useGameState.ts +++ b/src/hooks/useGameState.ts @@ -5,56 +5,112 @@ import { GameService } from '../services/gameService'; export function useGameState() { const [games, setGames] = useState([]); const [filter, setFilter] = useState('all'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - // Load games from localStorage on mount + // Load games from IndexedDB on mount useEffect(() => { - const savedGames = GameService.loadGames(); - setGames(savedGames); + const loadGames = async () => { + try { + setLoading(true); + setError(null); + const savedGames = await GameService.loadGames(); + setGames(savedGames); + } catch (err) { + console.error('Failed to load games:', err); + setError('Failed to load games from storage'); + } finally { + setLoading(false); + } + }; + + loadGames(); }, []); - // Save games to localStorage whenever games change - useEffect(() => { - GameService.saveGames(games); - }, [games]); - - const addGame = useCallback((gameData: NewGameData): number => { - const newGame = GameService.createGame(gameData); - setGames(prevGames => [newGame, ...prevGames]); - return newGame.id; + const addGame = useCallback(async (gameData: NewGameData): Promise => { + try { + const newGame = GameService.createGame(gameData); + await GameService.saveGame(newGame); + setGames(prevGames => [newGame, ...prevGames]); + return newGame.id; + } catch (err) { + console.error('Failed to add game:', err); + setError('Failed to save new game'); + throw err; + } }, []); - const updateGame = useCallback((gameId: number, updatedGame: Game) => { - setGames(prevGames => - prevGames.map(game => game.id === gameId ? updatedGame : game) - ); + const updateGame = useCallback(async (gameId: number, updatedGame: Game) => { + try { + await GameService.saveGame(updatedGame); + setGames(prevGames => + prevGames.map(game => game.id === gameId ? updatedGame : game) + ); + } catch (err) { + console.error('Failed to update game:', err); + setError('Failed to save game changes'); + throw err; + } }, []); - const deleteGame = useCallback((gameId: number) => { - setGames(prevGames => prevGames.filter(game => game.id !== gameId)); + const deleteGame = useCallback(async (gameId: number) => { + try { + await GameService.deleteGame(gameId); + setGames(prevGames => prevGames.filter(game => game.id !== gameId)); + } catch (err) { + console.error('Failed to delete game:', err); + setError('Failed to delete game'); + throw err; + } }, []); const getGameById = useCallback((gameId: number): Game | undefined => { return games.find(game => game.id === gameId); }, [games]); - const getFilteredGames = useCallback((): Game[] => { - return games - .filter(game => { + const getFilteredGames = useCallback(async (): Promise => { + try { + return await GameService.getGamesByFilter(filter); + } catch (err) { + console.error('Failed to get filtered games:', err); + setError('Failed to load filtered games'); + return games.filter(game => { if (filter === 'active') return game.status === 'active'; if (filter === 'completed') return game.status === 'completed'; return true; - }) - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - }, [games, filter]); + }); + } + }, [filter, games]); const getPlayerNameHistory = useCallback((): string[] => { - return GameService.getPlayerNameHistory(games); + // Extract player names from current games for immediate UI use + const nameLastUsed: Record = {}; + + games.forEach(game => { + const timestamp = new Date(game.updatedAt).getTime(); + + if ('players' in game) { + // EndlosGame + game.players.forEach(player => { + nameLastUsed[player.name] = Math.max(nameLastUsed[player.name] || 0, timestamp); + }); + } else { + // StandardGame + if (game.player1) nameLastUsed[game.player1] = Math.max(nameLastUsed[game.player1] || 0, timestamp); + if (game.player2) nameLastUsed[game.player2] = Math.max(nameLastUsed[game.player2] || 0, timestamp); + if (game.player3) nameLastUsed[game.player3] = Math.max(nameLastUsed[game.player3] || 0, timestamp); + } + }); + + return [...new Set(Object.keys(nameLastUsed))].sort((a, b) => nameLastUsed[b] - nameLastUsed[a]); }, [games]); return { games, filter, setFilter, + loading, + error, addGame, updateGame, deleteGame, diff --git a/src/services/gameService.ts b/src/services/gameService.ts index d5dc25a..2bf14a8 100644 --- a/src/services/gameService.ts +++ b/src/services/gameService.ts @@ -1,29 +1,130 @@ import type { Game, GameType, StandardGame, EndlosGame, NewGameData } from '../types/game'; +import { IndexedDBService } from './indexedDBService'; const LOCAL_STORAGE_KEY = 'bscscore_games'; export class GameService { /** - * Load games from localStorage + * Load games from IndexedDB (with localStorage fallback) */ - static loadGames(): Game[] { + static async loadGames(): Promise { + try { + // Try IndexedDB first + await IndexedDBService.init(); + const games = await IndexedDBService.loadGames(); + + if (games.length > 0) { + console.log(`Loaded ${games.length} games from IndexedDB`); + return games; + } + + // Fallback to localStorage if IndexedDB is empty + console.log('IndexedDB empty, checking localStorage for migration...'); + return this.migrateFromLocalStorage(); + + } catch (error) { + console.error('Error loading games from IndexedDB:', error); + // Fallback to localStorage + return this.migrateFromLocalStorage(); + } + } + + /** + * Migrate data from localStorage to IndexedDB + */ + private static async migrateFromLocalStorage(): Promise { try { const savedGames = localStorage.getItem(LOCAL_STORAGE_KEY); - return savedGames ? JSON.parse(savedGames) : []; + if (!savedGames) return []; + + const parsed = JSON.parse(savedGames); + if (!Array.isArray(parsed)) { + console.warn('Invalid games data in localStorage, resetting to empty array'); + return []; + } + + // Migrate to IndexedDB + console.log(`Migrating ${parsed.length} games from localStorage to IndexedDB...`); + for (const game of parsed) { + await IndexedDBService.saveGame(game); + } + + // Clear localStorage after successful migration + localStorage.removeItem(LOCAL_STORAGE_KEY); + console.log('Migration completed, localStorage cleared'); + + return parsed; } catch (error) { - console.error('Error loading games from localStorage:', error); + console.error('Error migrating from localStorage:', error); + // Clear corrupted data + localStorage.removeItem(LOCAL_STORAGE_KEY); return []; } } /** - * Save games to localStorage + * Save a single game to IndexedDB */ - static saveGames(games: Game[]): void { + static async saveGame(game: Game): Promise { try { - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(games)); + if (!game || typeof game.id !== 'number') { + console.error('Invalid game data provided to saveGame'); + return; + } + + await IndexedDBService.saveGame(game); + + // Update player statistics + await this.updatePlayerStats(game); + } catch (error) { - console.error('Error saving games to localStorage:', error); + console.error('Error saving game to IndexedDB:', error); + throw error; + } + } + + /** + * Save multiple games to IndexedDB + */ + static async saveGames(games: Game[]): Promise { + try { + if (!Array.isArray(games)) { + console.error('Invalid games data provided to saveGames'); + return; + } + + // Save each game individually + for (const game of games) { + await this.saveGame(game); + } + + } catch (error) { + console.error('Error saving games to IndexedDB:', error); + throw error; + } + } + + /** + * Update player statistics when a game is saved + */ + private static async updatePlayerStats(game: Game): Promise { + try { + if ('players' in game) { + // EndlosGame + for (const player of game.players) { + await IndexedDBService.updatePlayerStats(player.name, 1, player.score); + } + } else { + // StandardGame + await IndexedDBService.updatePlayerStats(game.player1, 1, game.score1); + await IndexedDBService.updatePlayerStats(game.player2, 1, game.score2); + if (game.player3) { + await IndexedDBService.updatePlayerStats(game.player3, 1, game.score3 || 0); + } + } + } catch (error) { + console.warn('Failed to update player statistics:', error); + // Don't throw here as it's not critical } } @@ -31,10 +132,24 @@ export class GameService { * Create a new game */ static createGame(gameData: NewGameData): Game { + // Validate input data + if (!gameData.player1?.trim() || !gameData.player2?.trim()) { + throw new Error('Player names are required'); + } + + if (!gameData.gameType) { + throw new Error('Game type is required'); + } + + const raceTo = parseInt(gameData.raceTo, 10); + if (isNaN(raceTo) || raceTo <= 0) { + throw new Error('Invalid race to value'); + } + const baseGame = { id: Date.now(), gameType: gameData.gameType as GameType, - raceTo: parseInt(gameData.raceTo, 10), + raceTo, status: 'active' as const, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -117,27 +232,50 @@ export class GameService { } /** - * Extract player name history from games + * Get player name history from IndexedDB */ - static getPlayerNameHistory(games: Game[]): string[] { - const nameLastUsed: Record = {}; - - games.forEach(game => { - const timestamp = new Date(game.updatedAt).getTime(); - - if ('players' in game) { - // EndlosGame - game.players.forEach(player => { - nameLastUsed[player.name] = Math.max(nameLastUsed[player.name] || 0, timestamp); - }); - } else { - // StandardGame - if (game.player1) nameLastUsed[game.player1] = Math.max(nameLastUsed[game.player1] || 0, timestamp); - if (game.player2) nameLastUsed[game.player2] = Math.max(nameLastUsed[game.player2] || 0, timestamp); - if (game.player3) nameLastUsed[game.player3] = Math.max(nameLastUsed[game.player3] || 0, timestamp); - } - }); + static async getPlayerNameHistory(): Promise { + try { + return await IndexedDBService.getPlayerNameHistory(); + } catch (error) { + console.error('Error loading player history from IndexedDB:', error); + return []; + } + } - return [...new Set(Object.keys(nameLastUsed))].sort((a, b) => nameLastUsed[b] - nameLastUsed[a]); + /** + * Get games by filter from IndexedDB + */ + static async getGamesByFilter(filter: 'all' | 'active' | 'completed'): Promise { + try { + return await IndexedDBService.getGamesByFilter(filter); + } catch (error) { + console.error('Error loading filtered games from IndexedDB:', error); + return []; + } + } + + /** + * Delete a game from IndexedDB + */ + static async deleteGame(gameId: number): Promise { + try { + await IndexedDBService.deleteGame(gameId); + } catch (error) { + console.error('Error deleting game from IndexedDB:', error); + throw error; + } + } + + /** + * Get storage information + */ + static async getStorageInfo(): Promise<{ gameCount: number; estimatedSize: number }> { + try { + return await IndexedDBService.getStorageInfo(); + } catch (error) { + console.error('Error getting storage info:', error); + return { gameCount: 0, estimatedSize: 0 }; + } } } \ No newline at end of file diff --git a/src/services/indexedDBService.ts b/src/services/indexedDBService.ts new file mode 100644 index 0000000..344a95f --- /dev/null +++ b/src/services/indexedDBService.ts @@ -0,0 +1,353 @@ +import type { Game, GameType, StandardGame, EndlosGame, NewGameData } from '../types/game'; + +const DB_NAME = 'BSCScoreDB'; +const DB_VERSION = 1; +const GAMES_STORE = 'games'; +const PLAYERS_STORE = 'players'; + +export interface GameStore { + id: number; + game: Game; + lastModified: number; + syncStatus: 'local' | 'synced' | 'pending' | 'conflict'; + version: number; + createdAt: number; +} + +export interface PlayerStore { + name: string; + lastUsed: number; + gameCount: number; + totalScore: number; +} + +export class IndexedDBService { + private static db: IDBDatabase | null = null; + private static initPromise: Promise | null = null; + + /** + * Initialize IndexedDB connection + */ + static async init(): Promise { + if (this.db) { + return this.db; + } + + if (this.initPromise) { + return this.initPromise; + } + + this.initPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + console.error('Failed to open IndexedDB:', request.error); + reject(request.error); + }; + + request.onsuccess = () => { + this.db = request.result; + console.log('IndexedDB initialized successfully'); + resolve(this.db); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Create games store + if (!db.objectStoreNames.contains(GAMES_STORE)) { + const gamesStore = db.createObjectStore(GAMES_STORE, { keyPath: 'id' }); + gamesStore.createIndex('lastModified', 'lastModified', { unique: false }); + gamesStore.createIndex('syncStatus', 'syncStatus', { unique: false }); + gamesStore.createIndex('gameType', 'game.gameType', { unique: false }); + gamesStore.createIndex('status', 'game.status', { unique: false }); + gamesStore.createIndex('createdAt', 'createdAt', { unique: false }); + } + + // Create players store + if (!db.objectStoreNames.contains(PLAYERS_STORE)) { + const playersStore = db.createObjectStore(PLAYERS_STORE, { keyPath: 'name' }); + playersStore.createIndex('lastUsed', 'lastUsed', { unique: false }); + playersStore.createIndex('gameCount', 'gameCount', { unique: false }); + } + }; + }); + + return this.initPromise; + } + + /** + * Get database instance + */ + private static async getDB(): Promise { + if (!this.db) { + await this.init(); + } + return this.db!; + } + + /** + * Execute a transaction with the database + */ + private static async executeTransaction( + storeNames: string | string[], + mode: IDBTransactionMode, + operation: (store: IDBObjectStore) => IDBRequest + ): Promise { + const db = await this.getDB(); + const transaction = db.transaction(storeNames, mode); + const store = transaction.objectStore(Array.isArray(storeNames) ? storeNames[0] : storeNames); + + return new Promise((resolve, reject) => { + const request = operation(store); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + transaction.onerror = () => reject(transaction.error); + }); + } + + /** + * Save a game to IndexedDB + */ + static async saveGame(game: Game): Promise { + const db = await this.getDB(); + const transaction = db.transaction([GAMES_STORE], 'readwrite'); + const store = transaction.objectStore(GAMES_STORE); + + const gameStore: GameStore = { + id: game.id, + game, + lastModified: Date.now(), + syncStatus: 'local', + version: 1, + createdAt: new Date(game.createdAt).getTime(), + }; + + return new Promise((resolve, reject) => { + const request = store.put(gameStore); + + request.onsuccess = () => { + console.log(`Game ${game.id} saved to IndexedDB`); + resolve(); + }; + + request.onerror = () => { + console.error(`Failed to save game ${game.id}:`, request.error); + reject(request.error); + }; + }); + } + + /** + * Load all games from IndexedDB + */ + static async loadGames(): Promise { + const db = await this.getDB(); + const transaction = db.transaction([GAMES_STORE], 'readonly'); + const store = transaction.objectStore(GAMES_STORE); + + return new Promise((resolve, reject) => { + const request = store.getAll(); + + request.onsuccess = () => { + const gameStores: GameStore[] = request.result; + const games = gameStores + .sort((a, b) => b.lastModified - a.lastModified) + .map(store => store.game); + + console.log(`Loaded ${games.length} games from IndexedDB`); + resolve(games); + }; + + request.onerror = () => { + console.error('Failed to load games from IndexedDB:', request.error); + reject(request.error); + }; + }); + } + + /** + * Load a specific game by ID + */ + static async loadGame(gameId: number): Promise { + const db = await this.getDB(); + const transaction = db.transaction([GAMES_STORE], 'readonly'); + const store = transaction.objectStore(GAMES_STORE); + + return new Promise((resolve, reject) => { + const request = store.get(gameId); + + request.onsuccess = () => { + const gameStore: GameStore | undefined = request.result; + resolve(gameStore ? gameStore.game : null); + }; + + request.onerror = () => { + console.error(`Failed to load game ${gameId}:`, request.error); + reject(request.error); + }; + }); + } + + /** + * Delete a game from IndexedDB + */ + static async deleteGame(gameId: number): Promise { + const db = await this.getDB(); + const transaction = db.transaction([GAMES_STORE], 'readwrite'); + const store = transaction.objectStore(GAMES_STORE); + + return new Promise((resolve, reject) => { + const request = store.delete(gameId); + + request.onsuccess = () => { + console.log(`Game ${gameId} deleted from IndexedDB`); + resolve(); + }; + + request.onerror = () => { + console.error(`Failed to delete game ${gameId}:`, request.error); + reject(request.error); + }; + }); + } + + /** + * Get games by filter + */ + static async getGamesByFilter(filter: 'all' | 'active' | 'completed'): Promise { + const db = await this.getDB(); + const transaction = db.transaction([GAMES_STORE], 'readonly'); + const store = transaction.objectStore(GAMES_STORE); + const index = store.index('status'); + + return new Promise((resolve, reject) => { + let request: IDBRequest; + + if (filter === 'all') { + request = store.getAll(); + } else { + request = index.getAll(filter); + } + + request.onsuccess = () => { + const gameStores: GameStore[] = request.result; + const games = gameStores + .sort((a, b) => b.lastModified - a.lastModified) + .map(store => store.game); + + resolve(games); + }; + + request.onerror = () => { + console.error(`Failed to load ${filter} games:`, request.error); + reject(request.error); + }; + }); + } + + /** + * Update player statistics + */ + static async updatePlayerStats(playerName: string, gameCount: number = 1, totalScore: number = 0): Promise { + const db = await this.getDB(); + const transaction = db.transaction([PLAYERS_STORE], 'readwrite'); + const store = transaction.objectStore(PLAYERS_STORE); + + return new Promise((resolve, reject) => { + // First, try to get existing player data + const getRequest = store.get(playerName); + + getRequest.onsuccess = () => { + const existingPlayer: PlayerStore | undefined = getRequest.result; + + const playerStore: PlayerStore = { + name: playerName, + lastUsed: Date.now(), + gameCount: existingPlayer ? existingPlayer.gameCount + gameCount : gameCount, + totalScore: existingPlayer ? existingPlayer.totalScore + totalScore : totalScore, + }; + + const putRequest = store.put(playerStore); + + putRequest.onsuccess = () => resolve(); + putRequest.onerror = () => reject(putRequest.error); + }; + + getRequest.onerror = () => reject(getRequest.error); + }); + } + + /** + * Get player name history sorted by last used + */ + static async getPlayerNameHistory(): Promise { + const db = await this.getDB(); + const transaction = db.transaction([PLAYERS_STORE], 'readonly'); + const store = transaction.objectStore(PLAYERS_STORE); + const index = store.index('lastUsed'); + + return new Promise((resolve, reject) => { + const request = index.getAll(); + + request.onsuccess = () => { + const players: PlayerStore[] = request.result; + const sortedNames = players + .sort((a, b) => b.lastUsed - a.lastUsed) + .map(player => player.name); + + resolve(sortedNames); + }; + + request.onerror = () => { + console.error('Failed to load player history:', request.error); + reject(request.error); + }; + }); + } + + /** + * Clear all data (for testing or reset) + */ + static async clearAllData(): Promise { + const db = await this.getDB(); + const transaction = db.transaction([GAMES_STORE, PLAYERS_STORE], 'readwrite'); + + return new Promise((resolve, reject) => { + const gamesStore = transaction.objectStore(GAMES_STORE); + const playersStore = transaction.objectStore(PLAYERS_STORE); + + const clearGames = gamesStore.clear(); + const clearPlayers = playersStore.clear(); + + let completed = 0; + const onComplete = () => { + completed++; + if (completed === 2) { + console.log('All IndexedDB data cleared'); + resolve(); + } + }; + + clearGames.onsuccess = onComplete; + clearPlayers.onsuccess = onComplete; + + clearGames.onerror = () => reject(clearGames.error); + clearPlayers.onerror = () => reject(clearPlayers.error); + }); + } + + /** + * Get storage usage information + */ + static async getStorageInfo(): Promise<{ gameCount: number; estimatedSize: number }> { + const games = await this.loadGames(); + const estimatedSize = JSON.stringify(games).length; + + return { + gameCount: games.length, + estimatedSize, + }; + } +} diff --git a/src/styles/index.css b/src/styles/index.css index f2b8ff8..f81533b 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -254,6 +254,42 @@ input:focus, select:focus { display: flex; } +/* Screen reader only content */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Focus styles for better accessibility */ +*:focus { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* Skip link for keyboard navigation */ +.skip-link { + position: absolute; + top: -40px; + left: 6px; + background: var(--color-primary); + color: white; + padding: 8px; + text-decoration: none; + border-radius: 4px; + z-index: 10000; +} + +.skip-link:focus { + top: 6px; +} + /* Responsive fullscreen toggle */ @media screen and (max-width: 480px) { .fullscreenToggle { diff --git a/src/types/game.ts b/src/types/game.ts index 32afb13..4dc7185 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -2,12 +2,22 @@ export type GameStatus = 'active' | 'completed'; export type GameType = '8-Ball' | '9-Ball' | '10-Ball'; +export type SyncStatus = 'local' | 'synced' | 'pending' | 'conflict'; + export interface Player { name: string; score: number; consecutiveFouls?: number; } +export interface GameAction { + type: 'score_change' | 'game_complete' | 'game_forfeit' | 'undo'; + player?: number; + change?: number; + timestamp: string; + description: string; +} + export interface BaseGame { id: number; gameType: GameType; @@ -15,8 +25,12 @@ export interface BaseGame { status: GameStatus; createdAt: string; updatedAt: string; - log: Array; - undoStack: Array; + log: GameAction[]; + undoStack: BaseGame[]; + // Sync fields for future online functionality + syncStatus?: SyncStatus; + version?: number; + lastModified?: number; } export interface StandardGame extends BaseGame { diff --git a/src/types/ui.ts b/src/types/ui.ts index 20bc30f..08cf58e 100644 --- a/src/types/ui.ts +++ b/src/types/ui.ts @@ -8,9 +8,11 @@ export interface ValidationState { message: string; } +import type { Game } from './game'; + export interface CompletionModalState { open: boolean; - game: any | null; + game: Game | null; } export interface AppScreen { @@ -21,9 +23,11 @@ export interface ButtonProps { variant?: 'primary' | 'secondary' | 'danger'; size?: 'small' | 'medium' | 'large'; disabled?: boolean; - children?: any; + children?: React.ReactNode; onClick?: () => void; 'aria-label'?: string; - style?: any; - [key: string]: any; // Allow additional props + style?: React.CSSProperties; + className?: string; + type?: 'button' | 'submit' | 'reset'; + [key: string]: unknown; // Allow additional props } \ No newline at end of file diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f104744..45fff76 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -45,4 +45,89 @@ export const BREAKPOINTS = { TABLET: '768px', DESKTOP: '1024px', LARGE_DESKTOP: '1200px', +} as const; + +export const UI_CONSTANTS = { + // Progress indicators + TOTAL_WIZARD_STEPS: 5, + + // Input styling + INPUT_FONT_SIZE: '1.2rem', + LABEL_FONT_SIZE: '1.3rem', + INPUT_MIN_HEIGHT: 48, + INPUT_PADDING_RIGHT: 44, + + // Button styling + ARROW_BUTTON_SIZE: 80, + ARROW_BUTTON_FONT_SIZE: 48, + QUICK_PICK_PADDING: '12px 20px', + QUICK_PICK_FONT_SIZE: '1.1rem', + + // Spacing + MARGIN_BOTTOM_LARGE: 32, + MARGIN_BOTTOM_MEDIUM: 24, + MARGIN_BOTTOM_SMALL: 16, + MARGIN_TOP_NAV: 48, + + // Quick pick limits + MAX_QUICK_PICKS: 10, + + // Animation durations + TOAST_DURATION: 3000, + TOAST_ANIMATION_DELAY: 300, +} as const; + +export const WIZARD_STEPS = { + PLAYER1: 1, + PLAYER2: 2, + PLAYER3: 3, + GAME_TYPE: 4, + RACE_TO: 5, +} as const; + +export const GAME_TYPE_OPTIONS = ['8-Ball', '9-Ball', '10-Ball'] as const; + +export const RACE_TO_QUICK_PICKS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const; + +export const RACE_TO_DEFAULT = 5; + +export const RACE_TO_INFINITY = 'Infinity'; + +export const ERROR_MESSAGES = { + PLAYER1_REQUIRED: 'Bitte Namen für Spieler 1 eingeben', + PLAYER2_REQUIRED: 'Bitte Namen für Spieler 2 eingeben', + GAME_TYPE_REQUIRED: 'Spieltyp muss ausgewählt werden', +} as const; + +export const ARIA_LABELS = { + BACK: 'Zurück', + NEXT: 'Weiter', + SKIP: 'Überspringen', + CLEAR_FIELD: 'Feld leeren', + SHOW_MORE_PLAYERS: 'Weitere Spieler anzeigen', + QUICK_PICK: (name: string) => `Schnellauswahl: ${name}`, + PLAYER_INPUT: (step: string) => `${step} Eingabe`, +} as const; + +export const FORM_CONFIG = { + MAX_PLAYER_NAME_LENGTH: 20, + CHARACTER_COUNT_WARNING_THRESHOLD: 15, + VALIDATION_DEBOUNCE_MS: 300, +} as const; + +export const ERROR_STYLES = { + CONTAINER: { + padding: '8px 12px', + backgroundColor: '#ffebee', + border: '1px solid #f44336', + borderRadius: '4px', + color: '#d32f2f', + fontSize: '0.875rem', + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + ICON: { + fontSize: '16px', + }, } as const; \ No newline at end of file diff --git a/src/utils/testIndexedDB.ts b/src/utils/testIndexedDB.ts new file mode 100644 index 0000000..9fb6889 --- /dev/null +++ b/src/utils/testIndexedDB.ts @@ -0,0 +1,140 @@ +import { IndexedDBService } from '../services/indexedDBService'; +import { GameService } from '../services/gameService'; +import type { NewGameData } from '../types/game'; + +/** + * Test utility for IndexedDB functionality + * Run this in the browser console to test the implementation + */ +export async function testIndexedDB() { + console.log('🧪 Starting IndexedDB tests...'); + + try { + // Test 1: Initialize IndexedDB + console.log('Test 1: Initializing IndexedDB...'); + await IndexedDBService.init(); + console.log('✅ IndexedDB initialized successfully'); + + // Test 2: Create a test game + console.log('Test 2: Creating test game...'); + const testGameData: NewGameData = { + player1: 'Test Player 1', + player2: 'Test Player 2', + player3: 'Test Player 3', + gameType: '8-Ball', + raceTo: '5' + }; + + const testGame = GameService.createGame(testGameData); + console.log('✅ Test game created:', testGame); + + // Test 3: Save game to IndexedDB + console.log('Test 3: Saving game to IndexedDB...'); + await IndexedDBService.saveGame(testGame); + console.log('✅ Game saved to IndexedDB'); + + // Test 4: Load games from IndexedDB + console.log('Test 4: Loading games from IndexedDB...'); + const loadedGames = await IndexedDBService.loadGames(); + console.log('✅ Games loaded:', loadedGames.length, 'games found'); + + // Test 5: Test filtering + console.log('Test 5: Testing game filtering...'); + const activeGames = await IndexedDBService.getGamesByFilter('active'); + const completedGames = await IndexedDBService.getGamesByFilter('completed'); + console.log('✅ Filtering works - Active:', activeGames.length, 'Completed:', completedGames.length); + + // Test 6: Test player statistics + console.log('Test 6: Testing player statistics...'); + await IndexedDBService.updatePlayerStats('Test Player 1', 1, 3); + await IndexedDBService.updatePlayerStats('Test Player 2', 1, 2); + const playerHistory = await IndexedDBService.getPlayerNameHistory(); + console.log('✅ Player statistics updated:', playerHistory); + + // Test 7: Test storage info + console.log('Test 7: Testing storage information...'); + const storageInfo = await IndexedDBService.getStorageInfo(); + console.log('✅ Storage info:', storageInfo); + + // Test 8: Test game deletion + console.log('Test 8: Testing game deletion...'); + await IndexedDBService.deleteGame(testGame.id); + const gamesAfterDelete = await IndexedDBService.loadGames(); + console.log('✅ Game deleted - Remaining games:', gamesAfterDelete.length); + + console.log('🎉 All IndexedDB tests passed!'); + + return { + success: true, + message: 'All tests passed successfully', + storageInfo, + playerHistory + }; + + } catch (error) { + console.error('❌ IndexedDB test failed:', error); + return { + success: false, + error: error.message, + message: 'Tests failed - check console for details' + }; + } +} + +/** + * Test localStorage migration + */ +export async function testLocalStorageMigration() { + console.log('🔄 Testing localStorage migration...'); + + try { + // Create some test data in localStorage + const testGames = [ + { + id: Date.now(), + gameType: '8-Ball', + raceTo: 5, + status: 'active', + player1: 'Migration Test 1', + player2: 'Migration Test 2', + score1: 0, + score2: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + log: [], + undoStack: [] + } + ]; + + localStorage.setItem('bscscore_games', JSON.stringify(testGames)); + console.log('✅ Test data created in localStorage'); + + // Test migration + const migratedGames = await GameService.loadGames(); + console.log('✅ Migration completed - Games loaded:', migratedGames.length); + + // Verify localStorage is cleared + const remainingData = localStorage.getItem('bscscore_games'); + console.log('✅ localStorage cleared after migration:', remainingData === null); + + return { + success: true, + message: 'Migration test passed', + migratedGames: migratedGames.length + }; + + } catch (error) { + console.error('❌ Migration test failed:', error); + return { + success: false, + error: error.message, + message: 'Migration test failed' + }; + } +} + +// Make functions available globally for console testing +if (typeof window !== 'undefined') { + (window as any).testIndexedDB = testIndexedDB; + (window as any).testLocalStorageMigration = testLocalStorageMigration; +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts index cad6531..f5598a8 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -27,39 +27,44 @@ export function validatePlayerName(name: string): ValidationResult { export function validateGameData(data: NewGameData): ValidationResult { const errors: string[] = []; - // Validate player names - const player1Validation = validatePlayerName(data.player1); - const player2Validation = validatePlayerName(data.player2); - - errors.push(...player1Validation.errors); - errors.push(...player2Validation.errors); + try { + // Validate player names + const player1Validation = validatePlayerName(data.player1); + const player2Validation = validatePlayerName(data.player2); + + errors.push(...player1Validation.errors); + errors.push(...player2Validation.errors); - // Check for duplicate player names - const playerNames = [data.player1.trim(), data.player2.trim()]; - if (data.player3?.trim()) { - const player3Validation = validatePlayerName(data.player3); - errors.push(...player3Validation.errors); - playerNames.push(data.player3.trim()); - } - - const uniqueNames = new Set(playerNames.filter(name => name.length > 0)); - if (uniqueNames.size !== playerNames.filter(name => name.length > 0).length) { - errors.push(VALIDATION_MESSAGES.DUPLICATE_PLAYER_NAMES); - } - - // Validate game type - if (!data.gameType?.trim()) { - errors.push(VALIDATION_MESSAGES.GAME_TYPE_REQUIRED); - } - - // Validate race to - if (!data.raceTo?.trim()) { - errors.push(VALIDATION_MESSAGES.RACE_TO_REQUIRED); - } else { - const raceToNumber = parseInt(data.raceTo, 10); - if (isNaN(raceToNumber) || raceToNumber <= 0) { - errors.push(VALIDATION_MESSAGES.RACE_TO_INVALID); + // Check for duplicate player names + const playerNames = [data.player1.trim(), data.player2.trim()]; + if (data.player3?.trim()) { + const player3Validation = validatePlayerName(data.player3); + errors.push(...player3Validation.errors); + playerNames.push(data.player3.trim()); } + + const uniqueNames = new Set(playerNames.filter(name => name.length > 0)); + if (uniqueNames.size !== playerNames.filter(name => name.length > 0).length) { + errors.push(VALIDATION_MESSAGES.DUPLICATE_PLAYER_NAMES); + } + + // Validate game type + if (!data.gameType?.trim()) { + errors.push(VALIDATION_MESSAGES.GAME_TYPE_REQUIRED); + } + + // Validate race to + if (!data.raceTo?.trim()) { + errors.push(VALIDATION_MESSAGES.RACE_TO_REQUIRED); + } else { + const raceToNumber = parseInt(data.raceTo, 10); + if (isNaN(raceToNumber) || raceToNumber <= 0) { + errors.push(VALIDATION_MESSAGES.RACE_TO_INVALID); + } + } + } catch (error) { + console.error('Validation error:', error); + errors.push('Ein unerwarteter Validierungsfehler ist aufgetreten'); } return {