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
This commit is contained in:
@@ -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 (
|
||||
<Layout>
|
||||
{navigation.screen === 'game-list' && (
|
||||
<GameListScreen
|
||||
games={gameState.getFilteredGames()}
|
||||
games={gameState.games}
|
||||
filter={gameState.filter}
|
||||
onFilterChange={gameState.setFilter}
|
||||
onShowGameDetail={navigation.showGameDetail}
|
||||
@@ -198,7 +233,16 @@ export default function App() {
|
||||
game={gameState.getGameById(navigation.currentGameId)}
|
||||
onUpdateScore={handleUpdateScore}
|
||||
onFinishGame={handleFinishGame}
|
||||
onUpdateGame={async (game: EndlosGame) => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
@@ -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]}
|
||||
</span>
|
||||
@@ -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}`}
|
||||
>-</button>
|
||||
<button
|
||||
className={styles['score-button']}
|
||||
disabled={isCompleted}
|
||||
onClick={() => handleScoreUpdate(idx+1, 1)}
|
||||
aria-label={`Punkt hinzufügen für ${name}`}
|
||||
title={`Punkt hinzufügen für ${name}`}
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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({
|
||||
<div
|
||||
className={styles['game-info']}
|
||||
onClick={() => 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`}
|
||||
>
|
||||
<div className={styles['game-type']}>
|
||||
{game.gameType}{game.raceTo ? ` | ${game.raceTo}` : ''}
|
||||
</div>
|
||||
<div className={styles['player-names']}>{playerNames}</div>
|
||||
<div className={styles['game-scores']}>{scores}</div>
|
||||
<div id={`game-${game.id}-description`} className="sr-only">
|
||||
{game.gameType} Spiel zwischen {playerNames} mit dem Stand {scores}.
|
||||
{game.status === 'completed' ? 'Abgeschlossen' : 'Aktiv'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { h } from 'preact';
|
||||
import styles from './Modal.module.css';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic modal dialog for confirmation.
|
||||
* @param {object} props
|
||||
* @param {boolean} props.open
|
||||
* @param {string} props.title
|
||||
* @param {string} props.message
|
||||
* @param {Function} props.onCancel
|
||||
* @param {Function} props.onConfirm
|
||||
* @returns {import('preact').VNode|null}
|
||||
*/
|
||||
const Modal = ({ open, title, message, onCancel, onConfirm }) => {
|
||||
const Modal = ({ open, title, message, onCancel, onConfirm }: ModalProps) => {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className={styles['modal'] + ' ' + styles['show']} role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
@@ -2,8 +2,26 @@ import { h } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import styles from './NewGame.module.css';
|
||||
import modalStyles from './PlayerSelectModal.module.css';
|
||||
import {
|
||||
UI_CONSTANTS,
|
||||
WIZARD_STEPS,
|
||||
GAME_TYPE_OPTIONS,
|
||||
RACE_TO_QUICK_PICKS,
|
||||
RACE_TO_DEFAULT,
|
||||
RACE_TO_INFINITY,
|
||||
ERROR_MESSAGES,
|
||||
ARIA_LABELS,
|
||||
FORM_CONFIG,
|
||||
ERROR_STYLES
|
||||
} from '../utils/constants';
|
||||
|
||||
const PlayerSelectModal = ({ players, onSelect, onClose }) => (
|
||||
interface PlayerSelectModalProps {
|
||||
players: string[];
|
||||
onSelect: (player: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const PlayerSelectModal = ({ players, onSelect, onClose }: PlayerSelectModalProps) => (
|
||||
<div className={modalStyles.modalOverlay} onClick={onClose}>
|
||||
<div className={modalStyles.modalContent} onClick={e => e.stopPropagation()}>
|
||||
<div className={modalStyles.modalHeader}>
|
||||
@@ -21,21 +39,22 @@ const PlayerSelectModal = ({ players, onSelect, onClose }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
interface PlayerStepProps {
|
||||
playerNameHistory: string[];
|
||||
onNext: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player 1 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 Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) => {
|
||||
const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
|
||||
const [player1, setPlayer1] = useState(initialValue);
|
||||
const [error, setError] = useState(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!player1) {
|
||||
@@ -49,22 +68,41 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
|
||||
}
|
||||
}, [player1, playerNameHistory]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!player1.trim()) {
|
||||
setError('Bitte Namen für Spieler 1 eingeben');
|
||||
const trimmedName = player1.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
setError(ERROR_MESSAGES.PLAYER1_REQUIRED);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedName.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
|
||||
setError(`Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
onNext(player1.trim());
|
||||
if (inputRef.current) {
|
||||
inputRef.current.setAttribute('aria-invalid', 'false');
|
||||
}
|
||||
onNext(trimmedName);
|
||||
};
|
||||
|
||||
const handleQuickPick = (name) => {
|
||||
const handleQuickPick = (name: string) => {
|
||||
setError(null);
|
||||
onNext(name);
|
||||
};
|
||||
|
||||
const handleModalSelect = (name) => {
|
||||
const handleModalSelect = (name: string) => {
|
||||
setIsModalOpen(false);
|
||||
handleQuickPick(name);
|
||||
};
|
||||
@@ -77,28 +115,62 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 1 Eingabe" autoComplete="off">
|
||||
<div className={styles['screen-title']}>Neues Spiel – Schritt 1/5</div>
|
||||
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
|
||||
<div className={styles['screen-title']}>Neues Spiel – Schritt {WIZARD_STEPS.PLAYER1}/{UI_CONSTANTS.TOTAL_WIZARD_STEPS}</div>
|
||||
<div className={styles['progress-indicator']} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_MEDIUM }}>
|
||||
<span className={styles['progress-dot'] + ' ' + styles['active']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
</div>
|
||||
<div className={styles['player-input'] + ' ' + styles['player1-input']} style={{ marginBottom: 32, position: 'relative' }}>
|
||||
<label htmlFor="player1-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 1</label>
|
||||
<div className={styles['player-input'] + ' ' + styles['player1-input']} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_LARGE, position: 'relative' }}>
|
||||
<label htmlFor="player1-input" style={{ fontSize: UI_CONSTANTS.LABEL_FONT_SIZE, fontWeight: 600 }}>Spieler 1</label>
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
<input
|
||||
id="player1-input"
|
||||
className={styles['name-input']}
|
||||
placeholder="Name Spieler 1"
|
||||
value={player1}
|
||||
onInput={e => setPlayer1(e.target.value)}
|
||||
onInput={(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const value = target.value;
|
||||
setPlayer1(value);
|
||||
|
||||
// Real-time validation feedback
|
||||
if (value.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
|
||||
setError(`Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`);
|
||||
target.setAttribute('aria-invalid', 'true');
|
||||
} else if (value.trim() && error) {
|
||||
setError(null);
|
||||
target.setAttribute('aria-invalid', 'false');
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
aria-label="Name Spieler 1"
|
||||
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
|
||||
aria-describedby="player1-help"
|
||||
style={{
|
||||
fontSize: UI_CONSTANTS.INPUT_FONT_SIZE,
|
||||
minHeight: UI_CONSTANTS.INPUT_MIN_HEIGHT,
|
||||
marginTop: 12,
|
||||
marginBottom: 12,
|
||||
width: '100%',
|
||||
paddingRight: UI_CONSTANTS.INPUT_PADDING_RIGHT
|
||||
}}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<div id="player1-help" className="sr-only">
|
||||
Geben Sie den Namen für Spieler 1 ein. Maximal {FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen erlaubt.
|
||||
</div>
|
||||
{player1.length > FORM_CONFIG.CHARACTER_COUNT_WARNING_THRESHOLD && (
|
||||
<div style={{
|
||||
fontSize: '0.875rem',
|
||||
color: player1.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH ? '#f44336' : '#ff9800',
|
||||
marginTop: '4px',
|
||||
textAlign: 'right'
|
||||
}}>
|
||||
{player1.length}/{FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen
|
||||
</div>
|
||||
)}
|
||||
{player1 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -127,25 +199,41 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
|
||||
</div>
|
||||
{filteredNames.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
|
||||
{filteredNames.slice(0, 10).map((name, idx) => (
|
||||
{filteredNames.slice(0, UI_CONSTANTS.MAX_QUICK_PICKS).map((name, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name + idx}
|
||||
className={styles['quick-pick-btn']}
|
||||
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
|
||||
style={{
|
||||
fontSize: UI_CONSTANTS.QUICK_PICK_FONT_SIZE,
|
||||
padding: UI_CONSTANTS.QUICK_PICK_PADDING,
|
||||
borderRadius: 8,
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => handleQuickPick(name)}
|
||||
aria-label={`Schnellauswahl: ${name}`}
|
||||
aria-label={ARIA_LABELS.QUICK_PICK(name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
{playerNameHistory.length > 10 && (
|
||||
{playerNameHistory.length > UI_CONSTANTS.MAX_QUICK_PICKS && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['quick-pick-btn']}
|
||||
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
|
||||
style={{
|
||||
fontSize: UI_CONSTANTS.QUICK_PICK_FONT_SIZE,
|
||||
padding: UI_CONSTANTS.QUICK_PICK_PADDING,
|
||||
borderRadius: 8,
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
aria-label="Weitere Spieler anzeigen"
|
||||
aria-label={ARIA_LABELS.SHOW_MORE_PLAYERS}
|
||||
>
|
||||
...
|
||||
</button>
|
||||
@@ -153,7 +241,20 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className={styles['validation-error']} style={{ marginBottom: 16 }}>{error}</div>}
|
||||
{error && (
|
||||
<div
|
||||
className={styles['validation-error']}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
...ERROR_STYLES.CONTAINER
|
||||
}}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span style={ERROR_STYLES.ICON}>⚠️</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{isModalOpen && (
|
||||
<PlayerSelectModal
|
||||
players={playerNameHistory}
|
||||
@@ -188,18 +289,12 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
|
||||
|
||||
/**
|
||||
* Player 2 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 Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) => {
|
||||
const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
|
||||
const [player2, setPlayer2] = useState(initialValue);
|
||||
const [error, setError] = useState(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
|
||||
const inputRef = useRef(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>(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<string | number>(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);
|
||||
};
|
||||
|
||||
1
src/components/PlayerInputStep.tsx
Normal file
1
src/components/PlayerInputStep.tsx
Normal file
@@ -0,0 +1 @@
|
||||
import { h } from "preact";
|
||||
@@ -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(() => {
|
||||
@@ -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 (
|
||||
<div className={styles['modal'] + ' ' + styles['show']} id="validation-modal" role="alertdialog" aria-modal="true" aria-labelledby="validation-modal-title">
|
||||
@@ -9,7 +9,10 @@ interface LayoutProps {
|
||||
export function Layout({ children, className = '' }: LayoutProps) {
|
||||
return (
|
||||
<div className={`${styles.layout} ${className}`}>
|
||||
<div className={styles.content}>
|
||||
<a href="#main-content" className="skip-link">
|
||||
Zum Hauptinhalt springen
|
||||
</a>
|
||||
<div className={styles.content} id="main-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,56 +5,112 @@ import { GameService } from '../services/gameService';
|
||||
export function useGameState() {
|
||||
const [games, setGames] = useState<Game[]>([]);
|
||||
const [filter, setFilter] = useState<GameFilter>('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<number> => {
|
||||
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<Game[]> => {
|
||||
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<string, number> = {};
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<Game[]> {
|
||||
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<Game[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, number> = {};
|
||||
|
||||
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<string[]> {
|
||||
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<Game[]> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
353
src/services/indexedDBService.ts
Normal file
353
src/services/indexedDBService.ts
Normal file
@@ -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<IDBDatabase> | null = null;
|
||||
|
||||
/**
|
||||
* Initialize IndexedDB connection
|
||||
*/
|
||||
static async init(): Promise<IDBDatabase> {
|
||||
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<IDBDatabase> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
return this.db!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a transaction with the database
|
||||
*/
|
||||
private static async executeTransaction<T>(
|
||||
storeNames: string | string[],
|
||||
mode: IDBTransactionMode,
|
||||
operation: (store: IDBObjectStore) => IDBRequest<T>
|
||||
): Promise<T> {
|
||||
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<void> {
|
||||
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<Game[]> {
|
||||
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<Game | null> {
|
||||
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<void> {
|
||||
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<Game[]> {
|
||||
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<GameStore[]>;
|
||||
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<any>;
|
||||
undoStack: Array<any>;
|
||||
log: GameAction[];
|
||||
undoStack: BaseGame[];
|
||||
// Sync fields for future online functionality
|
||||
syncStatus?: SyncStatus;
|
||||
version?: number;
|
||||
lastModified?: number;
|
||||
}
|
||||
|
||||
export interface StandardGame extends BaseGame {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
140
src/utils/testIndexedDB.ts
Normal file
140
src/utils/testIndexedDB.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user