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

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