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:
Frank Schwenk
2025-10-30 09:36:17 +01:00
parent e89ae1039d
commit 8085d2ecc8
20 changed files with 1288 additions and 277 deletions

View File

@@ -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);
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(),
};
// Add undo state for standard games
const gameWithHistory = {
...updatedGame,
undoStack: [...(game.undoStack || []), game],
updatedAt: new Date().toISOString(),
};
gameState.updateGame(navigation.currentGameId, gameWithHistory);
await gameState.updateGame(navigation.currentGameId, gameWithHistory);
// Check for completion
if (GameService.isGameCompleted(gameWithHistory)) {
completionModal.openCompletionModal(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(),
};
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);
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(),
});
try {
await gameState.updateGame(navigation.currentGameId, {
...gameState.getGameById(navigation.currentGameId)!,
status: 'completed',
updatedAt: new Date().toISOString(),
});
completionModal.closeCompletionModal();
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}
/>
)}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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"

View File

@@ -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">

View File

@@ -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);
};

View File

@@ -0,0 +1 @@
import { h } from "preact";

View File

@@ -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(() => {

View File

@@ -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">

View File

@@ -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>

View File

@@ -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,

View File

@@ -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> = {};
static async getPlayerNameHistory(): Promise<string[]> {
try {
return await IndexedDBService.getPlayerNameHistory();
} catch (error) {
console.error('Error loading player history from IndexedDB:', error);
return [];
}
}
games.forEach(game => {
const timestamp = new Date(game.updatedAt).getTime();
/**
* 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 [];
}
}
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);
}
});
/**
* 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;
}
}
return [...new Set(Object.keys(nameLastUsed))].sort((a, b) => nameLastUsed[b] - nameLastUsed[a]);
/**
* 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 };
}
}
}

View 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,
};
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -46,3 +46,88 @@ export const BREAKPOINTS = {
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
View 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;
}

View File

@@ -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);
try {
// Validate player names
const player1Validation = validatePlayerName(data.player1);
const player2Validation = validatePlayerName(data.player2);
errors.push(...player1Validation.errors);
errors.push(...player2Validation.errors);
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 {