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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
353
src/services/indexedDBService.ts
Normal file
353
src/services/indexedDBService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user