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