Refactor BSC Score to Astro, TypeScript, and modular architecture

This commit is contained in:
Cursor Agent
2025-06-24 11:44:19 +00:00
parent bcf793b9e3
commit 6f626c9977
30 changed files with 1836 additions and 497 deletions

49
src/utils/constants.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { GameType } from '../types/game';
export const GAME_TYPES: Array<{ value: GameType; label: string; defaultRaceTo: number }> = [
{ value: '8-Ball', label: '8-Ball', defaultRaceTo: 5 },
{ value: '9-Ball', label: '9-Ball', defaultRaceTo: 9 },
{ value: '10-Ball', label: '10-Ball', defaultRaceTo: 10 },
{ value: '14/1 endlos', label: '14/1 Endlos', defaultRaceTo: 150 },
];
export const RACE_TO_OPTIONS = [
{ value: '5', label: 'Race to 5' },
{ value: '7', label: 'Race to 7' },
{ value: '9', label: 'Race to 9' },
{ value: '11', label: 'Race to 11' },
{ value: '15', label: 'Race to 15' },
{ value: '25', label: 'Race to 25' },
{ value: '50', label: 'Race to 50' },
{ value: '100', label: 'Race to 100' },
{ value: '150', label: 'Race to 150' },
];
export const LOCAL_STORAGE_KEYS = {
GAMES: 'bscscore_games',
SETTINGS: 'bscscore_settings',
PLAYER_HISTORY: 'bscscore_player_history',
} as const;
export const APP_CONFIG = {
MAX_PLAYER_NAME_LENGTH: 20,
MAX_GAME_HISTORY: 100,
MAX_PLAYER_HISTORY: 50,
UNDO_STACK_SIZE: 10,
} as const;
export const VALIDATION_MESSAGES = {
PLAYER_NAME_REQUIRED: 'Spielername ist erforderlich',
PLAYER_NAME_TOO_LONG: `Spielername darf maximal ${APP_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`,
GAME_TYPE_REQUIRED: 'Spieltyp muss ausgewählt werden',
RACE_TO_REQUIRED: 'Race-to Wert ist erforderlich',
RACE_TO_INVALID: 'Race-to Wert muss eine positive Zahl sein',
DUPLICATE_PLAYER_NAMES: 'Spielernamen müssen eindeutig sein',
} as const;
export const BREAKPOINTS = {
MOBILE: '480px',
TABLET: '768px',
DESKTOP: '1024px',
LARGE_DESKTOP: '1200px',
} as const;

102
src/utils/gameUtils.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { Game, StandardGame, EndlosGame } from '../types/game';
/**
* Game utility functions for common operations
*/
export function isEndlosGame(game: Game): game is EndlosGame {
return 'players' in game;
}
export function isStandardGame(game: Game): game is StandardGame {
return !('players' in game);
}
export function formatGameTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
if (diffInHours < 1) {
return 'vor wenigen Minuten';
} else if (diffInHours < 24) {
return `vor ${Math.floor(diffInHours)} Stunde${Math.floor(diffInHours) !== 1 ? 'n' : ''}`;
} else if (diffInHours < 168) { // 7 days
const days = Math.floor(diffInHours / 24);
return `vor ${days} Tag${days !== 1 ? 'en' : ''}`;
} else {
return date.toLocaleDateString('de-DE');
}
}
export function getGameDuration(game: Game): string {
const start = new Date(game.createdAt);
const end = new Date(game.updatedAt);
const diffInMinutes = (end.getTime() - start.getTime()) / (1000 * 60);
if (diffInMinutes < 60) {
return `${Math.floor(diffInMinutes)} Min`;
} else {
const hours = Math.floor(diffInMinutes / 60);
const minutes = Math.floor(diffInMinutes % 60);
return `${hours}h ${minutes}m`;
}
}
export function calculateGameProgress(game: Game): number {
if (isEndlosGame(game)) {
const maxScore = Math.max(...game.players.map(p => p.score));
return Math.min((maxScore / game.raceTo) * 100, 100);
} else {
const scores = [game.score1, game.score2, game.score3 || 0];
const maxScore = Math.max(...scores);
return Math.min((maxScore / game.raceTo) * 100, 100);
}
}
export function getGameWinner(game: Game): string | null {
if (game.status !== 'completed') return null;
if ('winner' in game && game.winner) {
return game.winner;
}
if (isEndlosGame(game)) {
const winner = game.players.find(player => player.score >= game.raceTo);
return winner?.name || null;
} else {
if (game.score1 >= game.raceTo) return game.player1;
if (game.score2 >= game.raceTo) return game.player2;
if (game.player3 && game.score3 && game.score3 >= game.raceTo) return game.player3;
}
return null;
}
export function getGamePlayers(game: Game): Array<{ name: string; score: number }> {
if (isEndlosGame(game)) {
return game.players.map(player => ({
name: player.name,
score: player.score,
}));
} else {
const players = [
{ name: game.player1, score: game.score1 },
{ name: game.player2, score: game.score2 },
];
if (game.player3) {
players.push({ name: game.player3, score: game.score3 || 0 });
}
return players;
}
}
export function validateGameData(data: any): boolean {
return !!(
data.player1?.trim() &&
data.player2?.trim() &&
data.gameType &&
data.raceTo &&
parseInt(data.raceTo) > 0
);
}

94
src/utils/validation.ts Normal file
View File

@@ -0,0 +1,94 @@
import { APP_CONFIG, VALIDATION_MESSAGES } from './constants';
import type { NewGameData } from '../types/game';
export interface ValidationResult {
isValid: boolean;
errors: string[];
}
export function validatePlayerName(name: string): ValidationResult {
const errors: string[] = [];
const trimmedName = name.trim();
if (!trimmedName) {
errors.push(VALIDATION_MESSAGES.PLAYER_NAME_REQUIRED);
}
if (trimmedName.length > APP_CONFIG.MAX_PLAYER_NAME_LENGTH) {
errors.push(VALIDATION_MESSAGES.PLAYER_NAME_TOO_LONG);
}
return {
isValid: errors.length === 0,
errors,
};
}
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);
// 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);
}
}
return {
isValid: errors.length === 0,
errors,
};
}
export function sanitizePlayerName(name: string): string {
return name
.trim()
.slice(0, APP_CONFIG.MAX_PLAYER_NAME_LENGTH)
.replace(/[^\w\s-]/g, ''); // Remove special characters except spaces and hyphens
}
export function validateRaceTo(value: string): ValidationResult {
const errors: string[] = [];
if (!value?.trim()) {
errors.push(VALIDATION_MESSAGES.RACE_TO_REQUIRED);
} else {
const numValue = parseInt(value, 10);
if (isNaN(numValue) || numValue <= 0) {
errors.push(VALIDATION_MESSAGES.RACE_TO_INVALID);
}
}
return {
isValid: errors.length === 0,
errors,
};
}