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

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