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

@@ -0,0 +1,130 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from './GameDetail.module.css';
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.
*/
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: string, type: 'success' | 'error' | 'info' = 'info') => {
setToast({ show: true, message, type });
};
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';
showToast(`${action} für ${playerName}`, 'success');
};
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]);
return (
<div className={styles['game-detail']}>
<div className={styles['game-title']}>
{game.gameType}{game.raceTo ? ` | Race to ${game.raceTo}` : ''}
</div>
<div className={styles['scores-container']}>
{playerNames.map((name, idx) => {
const currentScore = scores[idx];
const progressPercentage = game.raceTo ? Math.min((currentScore / game.raceTo) * 100, 100) : 0;
return (
<div
className={styles['player-score'] + (name === 'Fränky' ? ' ' + styles['franky'] : '')}
key={name + idx}
>
<span className={styles['player-name']}>{name}</span>
<div className={styles['progress-bar']}>
<div
className={styles['progress-fill']}
style={{ width: `${progressPercentage}%` }}
/>
</div>
<span
className={styles['score']}
id={`score${idx + 1}`}
onClick={() => !isCompleted && onUpdateScore(idx + 1, 1)}
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>
<div className={styles['score-buttons']}>
<button
className={styles['score-button']}
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>
);
})}
</div>
<div className={styles['game-detail-controls']}>
<button className="btn" onClick={onBack} aria-label="Zurück zur Liste">Zurück zur Liste</button>
{onUndo && (
<button
className="btn btn--secondary"
onClick={() => {
onUndo();
showToast('Letzte Aktion rückgängig gemacht', 'info');
}}
aria-label="Rückgängig"
>
Rückgängig
</button>
)}
<button className="btn" disabled={isCompleted} onClick={onFinishGame} aria-label={isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}>{isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}</button>
</div>
<Toast
show={toast.show}
message={toast.message}
type={toast.type}
onClose={() => setToast({ show: false, message: '', type: 'info' })}
/>
</div>
);
};
export default GameDetail;