40 Commits

Author SHA1 Message Date
Frank Schwenk
6da7a5f4e2 Merge feature/winnerbreak-wechselbreak-#28 into main (Refs #28) 2025-10-30 14:07:38 +01:00
Frank Schwenk
d22bbdb3dc fix(#28): preselect Winnerbreak on break rule step
- Default selection set to 'winnerbreak' so right arrow is available immediately
- Fallback to stored lastBreakRule or Winnerbreak

Refs #28
2025-10-30 14:04:11 +01:00
Frank Schwenk
3e2264ad9d fix(#28): disable BreakRule right arrow until selection made
- BreakRuleStep now starts with no selection when not prefilled
- Right arrow disabled and dimmed until a rule is chosen (like game type)
- Keeps auto-advance on clicking a rule

Refs #28
2025-10-30 13:57:05 +01:00
Frank Schwenk
77173718c1 fix(#28): add right arrow to BreakRule step and correct back nav
- BreakRule step now shows a right arrow that advances using current selection
- Back navigation from BreakRule returns to Race To instead of exiting to list

Refs #28
2025-10-30 13:47:13 +01:00
Frank Schwenk
ed90b47348 fix(#28): use right arrow on Race To step and fix type issue
- Replace checkmark with right arrow on Race To submit button
- Cast raceTo to string for parseInt in quick-pick highlight

Refs #28
2025-10-30 13:44:20 +01:00
Frank Schwenk
b152575e61 fix(#28): show progress indicator on all steps with 7-step count
- Added indicator to BreakRule and BreakOrder steps
- Updated all steps to display 7 dots and correct active state

Refs #28
2025-10-30 12:32:13 +01:00
Frank Schwenk
f88db204f7 feat(#28): persist last-used break settings in localStorage
- Preload BreakRule and BreakOrder steps from localStorage defaults
- Save selections (rule, first, second) when chosen to speed future setup

Refs #28
2025-10-30 12:27:01 +01:00
Frank Schwenk
bc1bc4b446 fix(#28): finalize game creation on first-break selection
- NewGameScreen now calls onCreateGame after BreakOrder step instead of stopping
- Ensures flow creates game and navigates to detail after selecting breaker

Refs #28
2025-10-30 12:15:38 +01:00
Frank Schwenk
75fc0668bb fix(#28): remove duplicate rendering of BreakRule/BreakOrder steps
- NewGameScreen accidentally rendered break steps twice; cleaned up conditionals

Refs #28
2025-10-30 12:13:59 +01:00
Frank Schwenk
d3083c8c68 chore(#28): use descriptive titles for each creation step
- Player 1/2/3: “Name Spieler X”
- Game type: “Spielart auswählen”
- Race to: “Race To auswählen”
- Break rule: “Break-Regel wählen”
- First break: “Wer bricht zuerst?”

Refs #28
2025-10-30 12:07:17 +01:00
Frank Schwenk
2e0855e781 chore(#28): restore new game step titles without step counters
- Reintroduce 'Neues Spiel' titles on all creation steps
- Remove only the 'Schritt X/Y' portions as requested

Refs #28
2025-10-30 12:03:20 +01:00
Frank Schwenk
81c7c9579b feat(#28): reorder new game steps and remove step labels
- Order: player1 → player2 → player3 → game type → race to → break type → first break
- Removed all step title labels from forms; kept progress dots only
- Adjusted navigation and back behavior accordingly

Refs #28
2025-10-30 12:01:39 +01:00
Frank Schwenk
dc1d9a23a9 feat(#28): add continue arrow and auto-advance in BreakOrder step
- Auto-advance when selecting first breaker for Winnerbreak and 2-player Wechselbreak
- Keep auto-advance after choosing second for 3-player Wechselbreak
- Add explicit right arrow to continue manually based on current selection

Refs #28
2025-10-30 11:51:36 +01:00
Frank Schwenk
a11d41f934 feat(#28): place breaker indicator inline to the right of player name
- Indicator now renders after the name, inline, sized to 1em height
- Tooltip/aria-label remains “Break”

Refs #28
2025-10-30 11:47:46 +01:00
Frank Schwenk
1bd9919b6b feat(#28): add break rule & order to games and UI
- Types: add BreakRule, break metadata to StandardGame, extend NewGameData/steps
- NewGame: add BreakRule and BreakOrder steps with auto-advance
- NewGameScreen: wire new steps into flow
- GameService: set up defaults, persist break order, compute next breaker on +1
- GameDetail: show breaker indicator; treat -1 as undo equivalent

Backfill defaults for existing games via service logic.

Refs #28
2025-10-30 11:34:26 +01:00
Frank Schwenk
147906af59 refactor: remove toast notifications from game detail
- Delete Toast component and styles
- Remove all toast usage from GameDetail

Simplifies UX and eliminates transient notifications.

Refs #26
2025-10-30 11:05:27 +01:00
Frank Schwenk
634d012097 fix(ui): prevent winner modal icons from clipping
Increase top padding, set overflow visible, and position decorative icons inside container so emojis are fully visible.

Refs #26
2025-10-30 10:50:24 +01:00
Frank Schwenk
301d5b131c feat(ux): auto-advance on game type and race selection
- Step 4: selecting a game type immediately advances to step 5
- Step 5: selecting Endlos or a quick-pick number immediately finalizes race-to

Improves flow by removing an extra click. No changes to validation.

Refs #26
2025-10-30 10:41:38 +01:00
Frank Schwenk
4c8b0cfed7 refactor(css): remove global :focus outline styles in index.css\n\nFocus rings were visually distracting for this app context; removed the global *:focus rule. Component-level focus where needed can be handled locally.\n\nRefs #26 2025-10-30 10:36:47 +01:00
Frank Schwenk
31ed600c97 fix(build): remove stray CSS brace causing esbuild minify warning
Fix unexpected '}' in src/styles/index.css after .btn--secondary:hover.\nEliminates Vite/esbuild css-syntax-error during production build.\n\nRefs #26
2025-10-30 10:34:30 +01:00
Frank Schwenk
d016868ff2 feat(ux): autofocus name inputs on new game steps\n\nFocus Player 1/2/3 inputs on mount and place caret at end for faster entry.\n\nNo behavior changes beyond focus; adheres to accessibility with native focus.\n\nRefs #26 2025-10-30 10:15:30 +01:00
Frank Schwenk
89300bc021 fix(ui): align delete icon inline in game list
Apply game-item grid layout to game list rows and switch to two-column layout (1fr auto) so the delete action has a fixed-width slot on the right.

Keeps existing delete button and accessibility attributes; prevents layout stretch.
No behavioral changes beyond layout; click targets unchanged.

Refs #26
2025-10-30 10:04:11 +01:00
Frank Schwenk
de502741e7 Merge github/main into main (resolve .gitea to upstream) 2025-10-30 09:44:14 +01:00
Frank Schwenk
8085d2ecc8 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
2025-10-30 09:36:17 +01:00
Frank Schwenk
e89ae1039d fix: prevent endlos games from ending immediately on first score
- Change endlos raceTo from 0 to Infinity to prevent automatic completion
- Update NewGame component to handle Infinity values properly
- Add Infinity checks in game completion and winner logic
- Fix game progress calculation for endless games

Fixes issue where selecting 'endlos' mode would end the match
immediately when any player scored their first point.
2025-10-28 16:44:57 +01:00
Frank Schwenk
8bbe3b9b70 refactor: consolidate game components and add toast notifications
- Remove EndlosGame support and GameDetail141.jsx component
- Add Toast notification system with CSS styling
- Refactor GameCompletionModal with enhanced styling
- Improve GameDetail component structure and styling
- Add BaseLayout.astro for consistent page structure
- Update gameService with cleaner logic
- Enhance global styles and remove unused constants
- Streamline navigation components
2025-10-28 16:30:39 +01:00
Frank Schwenk
d1e1616faa Merge pull request #2 from froxxxy/cursor/fix-ui-flaws-for-tablet-aesthetics-5dab
Fix ui flaws for tablet aesthetics
2025-06-24 14:03:44 +02:00
Cursor Agent
6058de5103 Refactor UI with design system, tablet optimization, and enhanced styling 2025-06-24 12:02:35 +00:00
Frank Schwenk
eb005b1c05 Merge pull request #1 from froxxxy/cursor/refactor-project-for-reusability-and-best-practices-b090
Refactor project for reusability and best practices
2025-06-24 13:46:13 +02:00
Cursor Agent
6f626c9977 Refactor BSC Score to Astro, TypeScript, and modular architecture 2025-06-24 11:44:19 +00:00
Frank Schwenk
bcf793b9e3 fix(14-1): re-rack logic, turn button label, and foul input
- Re-rack logic fixed to accumulate and score correctly
- Turn switch button now shows two opposing arrows (⇄)
- Two foul buttons replaced by a single 'Foul -1' button styled like re-rack, can be pressed multiple times

Refs #26
2025-06-24 11:06:20 +02:00
Frank Schwenk
e6a5dcebbe feat(14-1): manual turn change and input accumulation
- Turns are now only changed by a big, prominent button
- Players can make multiple inputs (balls left, fouls, re-rack) before changing turns
- Players can change turn with no input (0 balls potted, 0 foul)
- All actions are accumulated in local state and finalized on turn change
- No automatic turn changes remain

Refs #26
2025-06-24 11:01:28 +02:00
Frank Schwenk
c6557dc050 feat(14-1): show sum of foul points per player per round in log table
- Table now groups turns into rounds (Aufnahmen) and displays sum of all foul points (including penalties) for each player in each round
- Improves clarity and accuracy of move log for 14/1

Refs #26
2025-06-24 10:53:37 +02:00
Frank Schwenk
b8bc3f8a5c fix(14-1): remove '0' ball button from end-of-turn grid
- The '0' button is no longer shown in the 'Bälle am Ende der Aufnahme' grid
- Only buttons for 1–15 balls are now available, matching real game scenarios

Refs #26
2025-06-24 10:40:28 +02:00
Frank Schwenk
ed552b3fbe style(14-1): make active player highly visible in game view
- Active player card now features thick, glowing orange border, strong background highlight, left accent bar, and animated pulse
- Player name and score have increased contrast for unmistakable visibility

Refs #26
2025-06-24 10:39:21 +02:00
Frank Schwenk
f0a91724d2 fix(14-1): correct re-rack scoring and table reset
- Player score incremented by (balls on table before re-rack + balls added - 15)
- Balls on table always set to 15 after re-rack
- Log entry includes all relevant details for traceability

Refs #26
2025-06-24 10:36:51 +02:00
Frank Schwenk
7d4cc30e97 style(new-game): restrict player color backgrounds to player input steps
- Only player input containers use the new color backgrounds, borders, and shadows
- Game type selection and other steps are visually unaffected
- Ensures clear, consistent player association without UI side effects

Refs #26
2025-06-24 10:17:58 +02:00
Frank Schwenk
592ba57286 fix(ui): prevent auto keyboard popup on player select
- Removed autoFocus from player input fields in new game creation
- Prevents on-screen keyboard from opening automatically on mobile

Refs #26
2025-06-24 10:13:30 +02:00
Frank Schwenk
0b5fa3f697 style(ui): make 'Neues Spiel' button visually prominent
- Increased size, padding, and border radius of the button
- Changed background to bold orange with white text
- Added a plus icon before the text
- Added drop shadow and modern hover effect for emphasis

Refs #26
2025-06-24 10:10:38 +02:00
Frank Schwenk
0247c7d384 feat(new-game): dynamic race-to quick picks and defaults for 14/1
- Passes selected game type to RaceToStep in App.jsx
- RaceToStep now shows quick pick values 60, 70, 80, 90, 100 (default 80) for 14/1, 1–9 (default 5) for others
- Number input always displays the default if none selected, updates on game type change
- Updates .gitea to reference issue #26 for traceability

Refs #26
2025-06-24 10:08:07 +02:00
50 changed files with 4563 additions and 1283 deletions

2
.gitea
View File

@@ -1 +1 @@
@https://gitea.schwenk.online/froxxxy/bscscore/issues/10
https://gitea.schwenk.online/froxxxy/bscscore/issues/26

259
README.md
View File

@@ -1,80 +1,219 @@
# BSC Score
# BSC Score - Pool Scoring Application
A modern, responsive web application for tracking billiards scores. Built with vanilla JavaScript and designed for mobile-first usage.
A modern, responsive pool/billiards scoring application built with **Astro** and **Preact**, following best practices for maintainability, performance, and reusability.
## Features
## Features
- Track scores for different billiards game types (8-Ball, 9-Ball, 10-Ball, 14/1)
- Support for "Race to X" games
- Real-time score tracking
- Game history with active and completed games
- Player name history and quick selection
- Mobile-optimized touch interface
- Offline support with local storage
- Dark theme design
- **Multi-game Support**: 8-Ball, 9-Ball, 10-Ball, and 14/1 Endlos
- **Real-time Scoring**: Live score tracking with undo functionality
- **Player Management**: Automatic player name history and suggestions
- **Game Management**: Create, track, and manage multiple games
- **Responsive Design**: Optimized for mobile and desktop
- **Progressive Web App**: Offline support and app-like experience
- **TypeScript**: Full type safety for better development experience
## Usage
## 🏗️ Architecture
1. Open `index.html` in your web browser
2. Create a new game by clicking "Neues Spiel"
3. Select or enter player names
4. Choose game type and optional "Race to X" setting
5. Use the score buttons to track points during the game
6. Complete the game when finished
7. View game history and filter by status
This project has been refactored following modern software development best practices:
## Development
### **Separation of Concerns**
- **Services Layer**: Game data management and localStorage operations
- **Custom Hooks**: Reusable state management logic
- **Components**: UI components with single responsibilities
- **Utils**: Pure utility functions for common operations
The application is built using:
- Vanilla JavaScript (ES6+)
- HTML5
- CSS3
- LocalStorage for data persistence
### **Type Safety**
- Full TypeScript implementation
- Comprehensive type definitions for game domain
- Type-safe component props and state management
No build process or dependencies required. Simply clone the repository and open `index.html` in a web browser.
### **Component Architecture**
```
src/
├── components/
│ ├── ui/ # Reusable UI components (Button, Card, Layout)
│ ├── screens/ # Screen-level components
│ └── ... # Feature-specific components
├── hooks/ # Custom React/Preact hooks
├── services/ # Business logic and data management
├── types/ # TypeScript type definitions
├── utils/ # Pure utility functions
└── styles/ # Global styles and CSS modules
```
## Project Structure
### **State Management**
- **useGameState**: Centralized game data management
- **useNavigation**: Screen and routing state
- **useModal**: Modal state management
- **Custom hooks**: Encapsulated, reusable state logic
The project consists of the following key files:
- `index.html`: Main application file containing HTML, CSS, and JavaScript
- `README.md`: Project documentation and setup instructions
- `LICENSE`: GNU GPLv3 license text
- `TODO.md`: Roadmap and planned features
### **Design System**
- Consistent design tokens and CSS custom properties
- Reusable UI components with variant support
- Responsive design patterns
- Accessibility-first approach
## Features in Detail
## 🚀 Getting Started
### Core Features
- Score tracking for multiple billiards game types
- Player name history with quick selection
- Game status management (active/completed)
- Local storage for offline functionality
- Mobile-optimized interface
### Prerequisites
- Node.js 18+
- npm or yarn
### User Interface
- Dark theme design
- Touch-friendly controls
- Responsive layout
- Game type selection
- Player name management
### Installation
```bash
# Clone the repository
git clone <repository-url>
cd bscscore
### Data Management
- Local storage persistence
- Game history tracking
- Player name history
- Status filtering
# Install dependencies
npm install
## Contributing
# Start development server
npm run dev
```
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
### Available Scripts
```bash
npm run dev # Start development server
npm run build # Build for production
npm run preview # Preview production build
```
## Roadmap
## 📁 Project Structure
See [TODO.md](TODO.md) for a list of proposed features and known issues.
### **Core Components**
- `App.tsx` - Main application component with orchestrated state management
- `screens/` - Screen-level components (GameList, NewGame, GameDetail)
- `ui/` - Reusable UI components following design system
## License
### **State Management**
- `hooks/useGameState.ts` - Game CRUD operations and persistence
- `hooks/useNavigation.ts` - Application routing and screen state
- `hooks/useModal.ts` - Modal state management
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
### **Business Logic**
- `services/gameService.ts` - Game creation, updates, and business rules
- `utils/gameUtils.ts` - Game-related utility functions
- `utils/validation.ts` - Input validation and sanitization
### **Type Definitions**
- `types/game.ts` - Game domain types
- `types/ui.ts` - UI component types
- `types/css-modules.d.ts` - CSS modules type support
## 🎯 Key Improvements
### **From Monolithic to Modular**
- **Before**: 360-line App component handling everything
- **After**: Separated concerns with focused, single-responsibility components
### **Type Safety**
- **Before**: JavaScript with PropTypes comments
- **After**: Full TypeScript with comprehensive type definitions
### **State Management**
- **Before**: All state in one component with prop drilling
- **After**: Custom hooks with proper encapsulation and reusability
### **Reusability**
- **Before**: Tightly coupled, single-use components
- **After**: Reusable UI components with variant support
### **Performance**
- **Before**: Client-side only rendering
- **After**: Astro's islands architecture with optimal hydration
### **Developer Experience**
- **Before**: No structure, mixed concerns
- **After**: Clear architecture, proper tooling, and documentation
## 🛠️ Technology Stack
- **Framework**: [Astro](https://astro.build/) - Islands architecture for optimal performance
- **UI Library**: [Preact](https://preactjs.com/) - Lightweight React alternative
- **Language**: [TypeScript](https://www.typescriptlang.org/) - Type safety and better DX
- **Styling**: CSS Modules with design tokens
- **Build Tool**: Vite (integrated with Astro)
## 📱 Progressive Web App
The application includes PWA features:
- Offline support with service worker
- App manifest for "Add to Home Screen"
- Optimized for mobile devices
- Fast loading with proper caching strategies
## 🎨 Design System
### **Design Tokens**
- Consistent color palette
- Standardized spacing and sizing
- Responsive breakpoints
- Accessibility-compliant contrast ratios
### **Component Variants**
- Button: `primary`, `secondary`, `danger`
- Card: `default`, `elevated`, `outlined`
- Sizes: `small`, `medium`, `large`
## 🧪 Best Practices Implemented
### **Code Quality**
- ✅ SOLID principles
- ✅ DRY (Don't Repeat Yourself)
- ✅ Single Responsibility Principle
- ✅ Proper separation of concerns
- ✅ TypeScript strict mode
### **Performance**
- ✅ Code splitting with Astro islands
- ✅ Optimized bundle size
- ✅ Efficient re-rendering with proper hooks
- ✅ CSS Modules for optimized styling
### **Accessibility**
- ✅ ARIA labels and roles
- ✅ Keyboard navigation support
- ✅ Screen reader compatibility
- ✅ High contrast support
### **Maintainability**
- ✅ Clear file structure
- ✅ Comprehensive documentation
- ✅ Type safety throughout
- ✅ Modular, testable components
## 🔧 Configuration
### **Astro Configuration**
- Preact integration with React compatibility
- TypeScript strict mode
- Optimized build settings
- Development server configuration
### **TypeScript Configuration**
- Strict type checking
- Modern ES2020+ features
- CSS Modules support
- Astro-specific types
## 📈 Future Improvements
- Unit and integration testing with Vitest
- E2E testing with Playwright
- Internationalization (i18n) support
- Advanced game statistics and analytics
- Real-time multiplayer support
- Game export/import functionality
## 🤝 Contributing
This codebase follows strict development principles:
1. Every feature must be type-safe
2. Components must be reusable and well-documented
3. Business logic must be separated from UI logic
4. All changes must follow the established architecture patterns
## 📄 License
[Include your license information here]

124
UI_IMPROVEMENTS.md Normal file
View File

@@ -0,0 +1,124 @@
# BSC Score App - UI Improvements for Tablet Optimization
## Overview
This document outlines the comprehensive UI improvements made to enhance the aesthetic appeal and tablet usage experience of the BSC Score application.
## Key Improvements Implemented
### 1. Design System Implementation
- **CSS Custom Properties**: Established a comprehensive design token system with consistent colors, spacing, typography, and transitions
- **Responsive Typography**: Tablet-specific font scaling for optimal readability
- **Touch Target Optimization**: Minimum 44px touch targets on mobile, 48-56px on tablets
- **Consistent Spacing**: 8px-based spacing system with semantic naming (--space-xs through --space-xxl)
### 2. Enhanced Color System
- **Primary Color**: Orange (#ff9800) with hover states
- **Semantic Colors**: Success, danger, warning with consistent usage
- **Surface Colors**: Improved contrast and visual hierarchy
- **Text Colors**: Primary, secondary, and muted text colors for better readability
### 3. Tablet-Specific Optimizations
#### Layout Improvements
- **Max-width adjustments**: 900px for tablets vs 1200px for desktop
- **Adaptive padding**: Increased spacing on larger screens
- **Grid layouts**: Improved symmetry in filter buttons and game type selection
#### Touch Experience
- **Larger touch targets**: 56px minimum on tablets
- **Hover effects**: Subtle animations and visual feedback
- **Improved button spacing**: Better gaps between interactive elements
### 4. Component-Specific Enhancements
#### Game List
- **Grid-based layout**: Improved symmetry with CSS Grid
- **Enhanced cards**: Better shadows, borders, and hover effects
- **Symmetric filter buttons**: Equal width distribution
- **Improved visual hierarchy**: Game type badges, centered player names, highlighted scores
- **Better delete buttons**: Larger touch targets with improved styling
#### New Game Wizard
- **Progress indicator**: Visual step indicator with active state animations
- **Consistent spacing**: Uniform gaps throughout the form
- **Enhanced input fields**: Better focus states and clear buttons
- **Improved navigation**: Larger arrow buttons with better visual feedback
- **Game type/Race-to selection**: Grid-based layout for better alignment
#### Button System
- **Unified styling**: Consistent button variants (primary, secondary, danger)
- **Micro-interactions**: Hover animations and press states
- **Size variants**: Small, medium, large with tablet adjustments
- **Focus indicators**: Improved accessibility with visible focus rings
### 5. Responsive Design Enhancements
#### Breakpoints
- **Mobile**: < 768px
- **Tablet**: 768px - 1024px
- **Desktop**: > 1024px
#### Tablet-Specific Features
- **Horizontal navigation**: Better layout for tablet landscape mode
- **Increased font sizes**: Improved readability on tablet screens
- **Enhanced spacing**: More generous padding and margins
- **Grid adjustments**: Optimized column layouts for tablet screens
### 6. Aesthetic Improvements
#### Visual Polish
- **Gradient backgrounds**: Subtle gradients on player input sections
- **Enhanced shadows**: Layered shadow system for depth
- **Smooth transitions**: 0.15s to 0.3s transition durations
- **Border radius consistency**: Unified corner rounding
- **Backdrop blur**: Modal overlays with blur effects
#### Typography
- **Font stack**: Inter font with system fallbacks
- **Improved line height**: 1.5 for better readability
- **Font smoothing**: Antialiased text rendering
- **Weight hierarchy**: Consistent font weight usage
#### Interactive Elements
- **Shimmer effects**: Subtle shine animation on buttons
- **Transform effects**: Slight lift on hover states
- **Color transitions**: Smooth color changes on state updates
- **Scale animations**: Micro-interactions for button presses
### 7. Accessibility Improvements
- **Focus management**: Visible focus indicators
- **Touch target sizes**: WCAG compliant minimum sizes
- **Color contrast**: Improved contrast ratios
- **Keyboard navigation**: Enhanced tab order and interactions
## Testing Recommendations
### Tablet Testing
1. **iPad (10.9")**: Test in both portrait and landscape orientations
2. **Android tablets**: Various screen sizes and densities
3. **Touch interactions**: Verify all buttons and inputs are easily tappable
4. **Scrolling performance**: Ensure smooth scrolling with -webkit-overflow-scrolling
### Cross-Device Testing
1. **Mobile phones**: Verify mobile breakpoint adjustments
2. **Desktop browsers**: Ensure desktop experience isn't degraded
3. **Different orientations**: Test rotation on tablets
## Performance Considerations
- **CSS Variables**: Minimal performance impact with better maintainability
- **Transform animations**: Hardware-accelerated for smooth performance
- **Optimized selectors**: Efficient CSS targeting without over-qualification
## Future Enhancements
1. **Dark/Light theme toggle**: Leveraging CSS custom properties
2. **Advanced animations**: Page transitions and micro-interactions
3. **Gesture support**: Swipe gestures for tablet navigation
4. **Enhanced accessibility**: Screen reader optimizations
## Browser Support
- **Modern browsers**: Chrome 88+, Firefox 85+, Safari 14+
- **CSS Grid**: Full support across target browsers
- **CSS Custom Properties**: Native support, no fallbacks needed
## Conclusion
These improvements create a more polished, professional, and tablet-optimized experience while maintaining consistency across all device sizes. The design system approach ensures easy maintenance and future scalability.

View File

@@ -1,9 +1,41 @@
// @ts-check
import { defineConfig } from 'astro/config';
import preact from '@astrojs/preact';
// https://astro.build/config
export default defineConfig({
integrations: [preact()]
integrations: [
preact({
compat: true, // Enable React compatibility for better ecosystem support
})
],
// Build optimizations
build: {
inlineStylesheets: 'auto',
},
// Vite configuration for development
vite: {
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
},
css: {
modules: {
localsConvention: 'camelCase',
},
},
optimizeDeps: {
include: ['preact/hooks'],
},
},
// Development server configuration
server: {
port: 3000,
host: true,
},
// Performance and SEO optimizations
compressHTML: true,
});

17
package-lock.json generated
View File

@@ -11,6 +11,9 @@
"@astrojs/preact": "^4.1.0",
"astro": "^5.9.0",
"preact": "^10.26.8"
},
"devDependencies": {
"@types/node": "^24.0.3"
}
},
"node_modules/@ampproject/remapping": {
@@ -1831,12 +1834,12 @@
}
},
"node_modules/@types/node": {
"version": "22.15.29",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.29.tgz",
"integrity": "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==",
"version": "24.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz",
"integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
"undici-types": "~7.8.0"
}
},
"node_modules/@types/unist": {
@@ -5098,9 +5101,9 @@
"license": "MIT"
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"license": "MIT"
},
"node_modules/unicode-properties": {

View File

@@ -12,5 +12,8 @@
"@astrojs/preact": "^4.1.0",
"astro": "^5.9.0",
"preact": "^10.26.8"
},
"devDependencies": {
"@types/node": "^24.0.3"
}
}

View File

@@ -1,359 +0,0 @@
import { h } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
import GameList from './GameList.jsx';
import GameDetail from './GameDetail.jsx';
import { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep } from './NewGame.jsx';
import Modal from './Modal.jsx';
import ValidationModal from './ValidationModal.jsx';
import GameCompletionModal from './GameCompletionModal.jsx';
import FullscreenToggle from './FullscreenToggle.jsx';
const LOCAL_STORAGE_KEY = 'bscscore_games';
/**
* Main App component for BSC Score
* @returns {import('preact').VNode}
*/
const App = () => {
const [games, setGames] = useState([]);
const [currentGameId, setCurrentGameId] = useState(null);
const [playerNameHistory, setPlayerNameHistory] = useState([]);
const [screen, setScreen] = useState('game-list');
const [modal, setModal] = useState({ open: false, gameId: null });
const [validation, setValidation] = useState({ open: false, message: '' });
const [completionModal, setCompletionModal] = useState({ open: false, game: null });
const [filter, setFilter] = useState('all');
const [newGameStep, setNewGameStep] = useState(null);
const [newGameData, setNewGameData] = useState({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
// Load games from localStorage on mount
useEffect(() => {
const savedGames = localStorage.getItem(LOCAL_STORAGE_KEY);
if (savedGames) {
setGames(JSON.parse(savedGames));
}
}, []);
// Save games to localStorage whenever games change
useEffect(() => {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(games));
// Update player name history
const nameLastUsed = {};
games.forEach(game => {
if (game.player1) nameLastUsed[game.player1] = Math.max(nameLastUsed[game.player1] || 0, new Date(game.updatedAt).getTime());
if (game.player2) nameLastUsed[game.player2] = Math.max(nameLastUsed[game.player2] || 0, new Date(game.updatedAt).getTime());
if (game.player3) nameLastUsed[game.player3] = Math.max(nameLastUsed[game.player3] || 0, new Date(game.updatedAt).getTime());
});
setPlayerNameHistory(
[...new Set(Object.keys(nameLastUsed))].sort((a, b) => nameLastUsed[b] - nameLastUsed[a])
);
}, [games]);
// Navigation handlers
const showGameList = useCallback(() => {
setScreen('game-list');
setCurrentGameId(null);
}, []);
const showNewGame = useCallback(() => {
setScreen('new-game');
setCurrentGameId(null);
setNewGameStep('player1');
setNewGameData({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
}, []);
const showGameDetail = useCallback((id) => {
setCurrentGameId(id);
setScreen('game-detail');
}, []);
// Game creation
const handleCreateGame = useCallback(({ player1, player2, player3, gameType, raceTo }) => {
const newGame = {
id: Date.now(),
gameType,
raceTo: parseInt(raceTo, 10),
status: 'active',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
log: [],
undoStack: [],
};
if (gameType === '14/1 endlos') {
const players = [{ name: player1, score: 0, consecutiveFouls: 0 }, { name: player2, score: 0, consecutiveFouls: 0 }];
if (player3) {
players.push({ name: player3, score: 0, consecutiveFouls: 0 });
}
newGame.players = players;
newGame.currentPlayer = null; // Set to null, will be chosen in GameDetail141
newGame.ballsOnTable = 15;
} else {
newGame.player1 = player1;
newGame.player2 = player2;
newGame.score1 = 0;
newGame.score2 = 0;
if (player3) {
newGame.player3 = player3;
newGame.score3 = 0;
}
}
setGames(g => [newGame, ...g]);
return newGame.id;
}, []);
// Game update for 14.1
const handleGameAction = useCallback((updatedGame) => {
const originalGame = games.find(game => game.id === currentGameId);
if (!originalGame) return;
// Add the original state to the undo stack before updating
const newUndoStack = [...(originalGame.undoStack || []), originalGame];
const gameWithHistory = {
...updatedGame,
undoStack: newUndoStack,
updatedAt: new Date().toISOString(),
};
setGames(games => games.map(game => (game.id === currentGameId ? gameWithHistory : game)));
// Check for raceTo completion
if (gameWithHistory.raceTo) {
const winner = gameWithHistory.players.find(p => p.score >= gameWithHistory.raceTo);
if (winner) {
setCompletionModal({ open: true, game: gameWithHistory });
}
}
}, [games, currentGameId]);
const handleUndo = useCallback(() => {
const game = games.find(g => g.id === currentGameId);
if (!game || !game.undoStack || game.undoStack.length === 0) return;
const lastState = game.undoStack[game.undoStack.length - 1];
const newUndoStack = game.undoStack.slice(0, -1);
setGames(g => g.map(gme => (gme.id === currentGameId ? { ...lastState, undoStack: newUndoStack } : gme)));
}, [games, currentGameId]);
const handleForfeit = useCallback(() => {
const game = games.find(g => g.id === currentGameId);
if (!game) return;
const winner = game.players.find((p, idx) => idx !== game.currentPlayer);
// In a 2 player game, this is simple. For >2, we need a winner selection.
// For now, assume the *other* player wins. This is fine for 2 players.
// We'll mark the game as complete with a note about the forfeit.
const forfeitedGame = {
...game,
status: 'completed',
winner: winner.name,
forfeitedBy: game.players[game.currentPlayer].name,
updatedAt: new Date().toISOString(),
};
setGames(g => g.map(gme => (gme.id === currentGameId ? forfeitedGame : gme)));
setCompletionModal({ open: true, game: forfeitedGame });
}, [games, currentGameId]);
// Score update
const handleUpdateScore = useCallback((player, change) => {
setGames(games => games.map(game => {
if (game.id !== currentGameId || game.status === 'completed') return game;
const updated = { ...game };
if (player === 1) updated.score1 = Math.max(0, updated.score1 + change);
if (player === 2) updated.score2 = Math.max(0, updated.score2 + change);
if (player === 3) updated.score3 = Math.max(0, updated.score3 + change);
updated.updatedAt = new Date().toISOString();
// Check for raceTo completion
if (updated.raceTo && (updated.score1 >= updated.raceTo || updated.score2 >= updated.raceTo || (updated.player3 && updated.score3 >= updated.raceTo))) {
setCompletionModal({ open: true, game: updated });
}
return updated;
}));
setCompletionModal({ open: false, game: null });
setScreen('game-detail');
}, [currentGameId]);
// Finish game
const handleFinishGame = useCallback(() => {
const game = games.find(g => g.id === currentGameId);
if (!game) return;
setCompletionModal({ open: true, game });
}, [games, currentGameId]);
const handleConfirmCompletion = useCallback(() => {
setGames(games => games.map(game => {
if (game.id !== currentGameId) return game;
return { ...game, status: 'completed', updatedAt: new Date().toISOString() };
}));
setCompletionModal({ open: false, game: null });
setScreen('game-detail');
}, [currentGameId]);
const handleRematch = useCallback(() => {
const completedGame = games.find(g => g.id === currentGameId);
if (!completedGame) return;
const newId = handleCreateGame({
player1: completedGame.player1,
player2: completedGame.player2,
player3: completedGame.player3,
gameType: completedGame.gameType,
raceTo: completedGame.raceTo,
});
setCompletionModal({ open: false, game: null });
showGameDetail(newId);
}, [games, currentGameId, handleCreateGame, showGameDetail]);
// Delete game
const handleDeleteGame = useCallback((id) => {
setModal({ open: true, gameId: id });
}, []);
const handleConfirmDelete = useCallback(() => {
setGames(games => games.filter(g => g.id !== modal.gameId));
setModal({ open: false, gameId: null });
setScreen('game-list');
}, [modal.gameId]);
const handleCancelDelete = useCallback(() => {
setModal({ open: false, gameId: null });
}, []);
// Validation modal
const showValidation = useCallback((message) => {
setValidation({ open: true, message });
}, []);
const closeValidation = useCallback(() => {
setValidation({ open: false, message: '' });
}, []);
// Step handlers
const handlePlayer1Next = (name) => {
setNewGameData(data => ({ ...data, player1: name }));
setNewGameStep('player2');
};
const handlePlayer2Next = (name) => {
setNewGameData(data => ({ ...data, player2: name }));
setNewGameStep('player3');
};
const handlePlayer3Next = (name) => {
setNewGameData(data => ({ ...data, player3: name }));
setNewGameStep('gameType');
};
const handleGameTypeNext = (type) => {
setNewGameData(data => ({ ...data, gameType: type, raceTo: type === '14/1 endlos' ? '150' : '50' }));
setNewGameStep('raceTo');
};
const handleRaceToNext = (raceTo) => {
const finalData = { ...newGameData, raceTo };
const newId = handleCreateGame(finalData);
showGameDetail(newId);
setNewGameStep(null);
setNewGameData({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
};
const handleCancelNewGame = useCallback(() => {
setScreen('game-list');
setNewGameStep(null);
setNewGameData({ player1: '', player2: '', player3: '', gameType: '', raceTo: '' });
}, []);
return (
<div className="screen-container">
{screen === 'game-list' && (
<div className="screen active">
<div className="screen-content">
<button className="nav-button new-game-button" onClick={showNewGame} aria-label="Neues Spiel starten">Neues Spiel</button>
<GameList
games={games}
filter={filter}
onShowGameDetail={showGameDetail}
onDeleteGame={handleDeleteGame}
setFilter={setFilter}
/>
</div>
</div>
)}
{screen === 'new-game' && (
<div className="screen active">
<div className="screen-content">
{newGameStep === 'player1' && (
<Player1Step
playerNameHistory={playerNameHistory}
onNext={handlePlayer1Next}
onCancel={handleCancelNewGame}
initialValue={newGameData.player1}
/>
)}
{newGameStep === 'player2' && (
<Player2Step
playerNameHistory={playerNameHistory}
onNext={handlePlayer2Next}
onCancel={() => setNewGameStep('player1')}
initialValue={newGameData.player2}
/>
)}
{newGameStep === 'player3' && (
<Player3Step
playerNameHistory={playerNameHistory}
onNext={handlePlayer3Next}
onCancel={() => setNewGameStep('player2')}
initialValue={newGameData.player3}
/>
)}
{newGameStep === 'gameType' && (
<GameTypeStep
onNext={handleGameTypeNext}
onCancel={() => setNewGameStep('player3')}
initialValue={newGameData.gameType}
/>
)}
{newGameStep === 'raceTo' && (
<RaceToStep
onNext={handleRaceToNext}
onCancel={() => setNewGameStep('gameType')}
initialValue={newGameData.raceTo}
/>
)}
</div>
</div>
)}
{screen === 'game-detail' && (
<div className="screen active">
<div className="screen-content">
<GameDetail
game={games.find(g => g.id === currentGameId)}
onUpdateScore={handleUpdateScore}
onFinishGame={handleFinishGame}
onUpdateGame={handleGameAction}
onUndo={handleUndo}
onForfeit={handleForfeit}
onBack={showGameList}
/>
</div>
</div>
)}
<Modal
open={modal.open}
title="Spiel löschen"
message="Möchten Sie das Spiel wirklich löschen?"
onCancel={handleCancelDelete}
onConfirm={handleConfirmDelete}
/>
<ValidationModal
open={validation.open}
message={validation.message}
onClose={closeValidation}
/>
<GameCompletionModal
open={completionModal.open}
game={completionModal.game}
onConfirm={handleConfirmCompletion}
onClose={() => setCompletionModal({ open: false, game: null })}
onRematch={handleRematch}
/>
<FullscreenToggle />
</div>
);
};
export default App;

275
src/components/App.tsx Normal file
View File

@@ -0,0 +1,275 @@
import { h } from 'preact';
import { useEffect, useCallback } from 'preact/hooks';
import { useGameState } from '../hooks/useGameState';
import { useNavigation, useNewGameWizard } from '../hooks/useNavigation';
import { useModal, useValidationModal, useCompletionModal } from '../hooks/useModal';
import { GameService } from '../services/gameService';
import type { StandardGame, Game, EndlosGame } from '../types/game';
import { Layout } from './ui/Layout';
import GameListScreen from './screens/GameListScreen';
import NewGameScreen from './screens/NewGameScreen';
import GameDetailScreen from './screens/GameDetailScreen';
import Modal from './Modal';
import ValidationModal from './ValidationModal';
import GameCompletionModal from './GameCompletionModal';
import FullscreenToggle from './FullscreenToggle';
/**
* Main App component for BSC Score
*/
export default function App() {
// State management hooks
const gameState = useGameState();
const navigation = useNavigation();
const newGameWizard = useNewGameWizard();
const modal = useModal();
const validationModal = useValidationModal();
const completionModal = useCompletionModal();
// Game lifecycle handlers
const handleCreateGame = useCallback(async (gameData: any) => {
try {
const gameId = await gameState.addGame(gameData);
newGameWizard.resetWizard();
navigation.showGameDetail(gameId);
} catch (error) {
console.error('Failed to create game:', error);
validationModal.showValidation('Failed to create game. Please try again.');
}
}, [gameState.addGame, newGameWizard.resetWizard, navigation.showGameDetail, validationModal.showValidation]);
const handleUpdateScore = useCallback(async (player: number, change: number) => {
if (!navigation.currentGameId) return;
const game = gameState.getGameById(navigation.currentGameId);
if (!game || game.status === 'completed') return;
try {
const updatedGame = GameService.updateGameScore(game as StandardGame, player, change);
// Add undo state for standard games
const gameWithHistory = {
...updatedGame,
undoStack: [...(game.undoStack || []), game],
updatedAt: new Date().toISOString(),
};
await gameState.updateGame(navigation.currentGameId, gameWithHistory);
// Check for completion
if (GameService.isGameCompleted(gameWithHistory)) {
completionModal.openCompletionModal(gameWithHistory);
}
} catch (error) {
console.error('Failed to update score:', error);
validationModal.showValidation('Failed to update score. Please try again.');
}
}, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, completionModal.openCompletionModal, validationModal.showValidation]);
const handleUndo = useCallback(async () => {
if (!navigation.currentGameId) return;
const game = gameState.getGameById(navigation.currentGameId);
if (!game?.undoStack?.length) return;
try {
const lastState = game.undoStack[game.undoStack.length - 1];
const newUndoStack = game.undoStack.slice(0, -1);
await gameState.updateGame(navigation.currentGameId, {
...lastState,
undoStack: newUndoStack,
} as Game);
} catch (error) {
console.error('Failed to undo:', error);
validationModal.showValidation('Failed to undo. Please try again.');
}
}, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, validationModal.showValidation]);
const handleForfeit = async () => {
if (!navigation.currentGameId) return;
const game = gameState.getGameById(navigation.currentGameId);
if (!game || !('players' in game)) return;
try {
const currentPlayerIndex = game.currentPlayer;
if (currentPlayerIndex === null) return;
const winner = game.players.find((_, idx) => idx !== currentPlayerIndex);
const forfeitedGame = {
...game,
status: 'completed' as const,
winner: winner?.name,
forfeitedBy: game.players[currentPlayerIndex].name,
updatedAt: new Date().toISOString(),
};
await gameState.updateGame(navigation.currentGameId, forfeitedGame);
completionModal.openCompletionModal(forfeitedGame);
} catch (error) {
console.error('Failed to forfeit game:', error);
validationModal.showValidation('Failed to forfeit game. Please try again.');
}
};
const handleFinishGame = () => {
if (!navigation.currentGameId) return;
const game = gameState.getGameById(navigation.currentGameId);
if (!game) return;
completionModal.openCompletionModal(game);
};
const handleConfirmCompletion = async () => {
if (!navigation.currentGameId) return;
try {
await gameState.updateGame(navigation.currentGameId, {
...gameState.getGameById(navigation.currentGameId)!,
status: 'completed',
updatedAt: new Date().toISOString(),
});
completionModal.closeCompletionModal();
} catch (error) {
console.error('Failed to complete game:', error);
validationModal.showValidation('Failed to complete game. Please try again.');
}
};
const handleRematch = async () => {
if (!navigation.currentGameId) return;
const completedGame = gameState.getGameById(navigation.currentGameId);
if (!completedGame) return;
let gameData;
if ('players' in completedGame) {
gameData = {
player1: completedGame.players[0]?.name || '',
player2: completedGame.players[1]?.name || '',
player3: completedGame.players[2]?.name || '',
gameType: completedGame.gameType,
raceTo: completedGame.raceTo.toString(),
};
} else {
gameData = {
player1: completedGame.player1,
player2: completedGame.player2,
player3: completedGame.player3 || '',
gameType: completedGame.gameType,
raceTo: completedGame.raceTo.toString(),
};
}
try {
const newGameId = await gameState.addGame(gameData);
completionModal.closeCompletionModal();
navigation.showGameDetail(newGameId);
} catch (error) {
console.error('Failed to create rematch:', error);
validationModal.showValidation('Failed to create rematch. Please try again.');
}
};
const handleDeleteGame = (gameId: number) => {
modal.openModal(gameId);
};
const handleConfirmDelete = async () => {
if (modal.modal.gameId) {
try {
await gameState.deleteGame(modal.modal.gameId);
modal.closeModal();
navigation.showGameList();
} catch (error) {
console.error('Failed to delete game:', error);
validationModal.showValidation('Failed to delete game. Please try again.');
}
}
};
return (
<Layout>
{navigation.screen === 'game-list' && (
<GameListScreen
games={gameState.games}
filter={gameState.filter}
onFilterChange={gameState.setFilter}
onShowGameDetail={navigation.showGameDetail}
onDeleteGame={handleDeleteGame}
onShowNewGame={() => {
newGameWizard.startWizard();
navigation.showNewGame();
}}
/>
)}
{navigation.screen === 'new-game' && (
<NewGameScreen
step={newGameWizard.newGameStep}
data={newGameWizard.newGameData}
playerHistory={gameState.getPlayerNameHistory()}
onStepChange={newGameWizard.nextStep}
onDataChange={newGameWizard.updateGameData}
onCreateGame={handleCreateGame}
onCancel={() => {
newGameWizard.resetWizard();
navigation.showGameList();
}}
onShowValidation={validationModal.showValidation}
/>
)}
{navigation.screen === 'game-detail' && navigation.currentGameId && (
<GameDetailScreen
game={gameState.getGameById(navigation.currentGameId)}
onUpdateScore={handleUpdateScore}
onFinishGame={handleFinishGame}
onUpdateGame={async (game: EndlosGame) => {
try {
await gameState.updateGame(navigation.currentGameId!, game);
} catch (error) {
console.error('Failed to update game:', error);
validationModal.showValidation('Failed to update game. Please try again.');
}
}}
onUndo={handleUndo}
onForfeit={handleForfeit}
onBack={navigation.showGameList}
/>
)}
<Modal
open={modal.modal.open}
title="Spiel löschen"
message="Möchten Sie das Spiel wirklich löschen?"
onCancel={modal.closeModal}
onConfirm={handleConfirmDelete}
/>
<ValidationModal
open={validationModal.validation.open}
message={validationModal.validation.message}
onClose={validationModal.closeValidation}
/>
<GameCompletionModal
open={completionModal.completionModal.open}
game={completionModal.completionModal.game}
onConfirm={handleConfirmCompletion}
onClose={completionModal.closeCompletionModal}
onRematch={handleRematch}
/>
<FullscreenToggle />
</Layout>
);
}

View File

@@ -0,0 +1,33 @@
---
// This is an Astro component that properly leverages SSR and islands
---
<!-- Use Astro's islands architecture for better performance -->
<!-- Only hydrate the interactive app component when needed -->
<div id="app-root">
<slot name="app-content" />
</div>
<style>
#app-root {
min-height: 100vh;
width: 100%;
}
/* Progressive enhancement styles */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
<script>
// Client-side progressive enhancement
document.addEventListener('DOMContentLoaded', () => {
// Add any progressive enhancement here
console.log('BSC Score App initialized');
});
</script>

View File

@@ -1,132 +0,0 @@
import { h } from 'preact';
import modalStyles from './Modal.module.css';
import styles from './GameCompletionModal.module.css';
const calculateStats = (game) => {
if (game.gameType !== '14/1 endlos' || !game.log) {
return null;
}
const stats = {};
game.players.forEach(p => {
stats[p.name] = {
totalPots: 0,
turnCount: 0,
highestRun: 0,
currentRun: 0,
};
});
for (const entry of game.log) {
if (entry.type === 'turn') {
const playerStats = stats[entry.player];
playerStats.turnCount += 1;
if (entry.ballsPotted > 0) {
playerStats.totalPots += entry.ballsPotted;
playerStats.currentRun += entry.ballsPotted;
} else {
// Run ends on a 0-pot turn
playerStats.highestRun = Math.max(playerStats.highestRun, playerStats.currentRun);
playerStats.currentRun = 0;
}
} else if (entry.type === 'foul') {
const playerStats = stats[entry.player];
playerStats.turnCount += 1;
// Run ends on a foul
playerStats.highestRun = Math.max(playerStats.highestRun, playerStats.currentRun);
playerStats.currentRun = 0;
}
}
// Final check for runs that extend to the end of the game
Object.values(stats).forEach(playerStats => {
playerStats.highestRun = Math.max(playerStats.highestRun, playerStats.currentRun);
});
// Calculate averages
Object.keys(stats).forEach(playerName => {
const ps = stats[playerName];
ps.avgPots = ps.turnCount > 0 ? (ps.totalPots / ps.turnCount).toFixed(2) : 0;
});
return stats;
};
/**
* Modal shown when a game is completed.
* @param {object} props
* @param {boolean} props.open
* @param {object} props.game
* @param {Function} props.onConfirm
* @param {Function} props.onClose
* @param {Function} props.onRematch
* @returns {import('preact').VNode|null}
*/
const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }) => {
if (!open || !game) return null;
let playerNames, scores, maxScore, winners, winnerText, gameStats;
if (game.gameType === '14/1 endlos') {
playerNames = game.players.map(p => p.name);
scores = game.players.map(p => p.score);
gameStats = calculateStats(game);
} else {
playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
}
if (game.forfeitedBy) {
winnerText = `${game.winner} hat gewonnen, da ${game.forfeitedBy} aufgegeben hat.`;
} else {
maxScore = Math.max(...scores);
winners = playerNames.filter((name, idx) => scores[idx] === maxScore);
winnerText = winners.length > 1
? `Unentschieden zwischen ${winners.join(' und ')}`
: `${winners[0]} hat gewonnen!`;
}
return (
<div id="game-completion-modal" className={modalStyles['modal'] + ' ' + modalStyles['show']} role="dialog" aria-modal="true" aria-labelledby="completion-modal-title">
<div className={modalStyles['modal-content']}>
<div className={modalStyles['modal-header']}>
<span className={modalStyles['modal-title']} id="completion-modal-title">Spiel beendet</span>
<button className={modalStyles['close-button']} onClick={onClose} aria-label="Schließen">×</button>
</div>
<div className={modalStyles['modal-body']}>
<div className={styles['final-scores']}>
{playerNames.map((name, idx) => (
<div className={styles['final-score']} key={name + idx}>
<span className={styles['player-name']}>{name}</span>
<span className={styles['score']}>{scores[idx]}</span>
</div>
))}
</div>
<div className={styles['winner-announcement']}><h3>{winnerText}</h3></div>
{gameStats && (
<div className={styles['stats-container']}>
<h4 className={styles['stats-title']}>Statistiken</h4>
{playerNames.map(name => (
<div key={name} className={styles['player-stats']}>
<div className={styles['player-name-stats']}>{name}</div>
<div className={styles['stat-item']}>Höchste Serie: <strong>{gameStats[name].highestRun}</strong></div>
<div className={styles['stat-item']}>Punkte / Aufnahme: <strong>{gameStats[name].avgPots}</strong></div>
</div>
))}
</div>
)}
</div>
<div className={modalStyles['modal-footer']}>
<button className={styles['btn'] + ' ' + styles['btn--warning']} onClick={onConfirm} aria-label="Bestätigen">Bestätigen</button>
<button className={styles['btn'] + ' ' + styles['btn--primary']} onClick={onRematch} aria-label="Rematch">Rematch</button>
<button className={styles['btn']} onClick={onClose} aria-label="Abbrechen">Abbrechen</button>
</div>
</div>
</div>
);
};
export default GameCompletionModal;

View File

@@ -26,18 +26,68 @@
.winner-announcement {
text-align: center;
margin: 20px 0 0 0;
padding: 18px 8px;
background: #43a047;
border-radius: 8px;
padding: 32px 16px 24px 16px; /* extra top padding to keep icons inside */
background: linear-gradient(135deg, #ff9800 0%, #ffa726 100%);
border-radius: 16px;
font-size: 1.2rem;
color: #fff;
color: #222;
font-weight: 700;
box-shadow: 0 8px 32px rgba(255, 152, 0, 0.3);
animation: celebrationPulse 2s ease-in-out infinite;
position: relative;
overflow: visible; /* avoid clipping decorative icons */
}
.winner-announcement::before {
content: '🎉';
position: absolute;
top: 6px;
left: 20px;
font-size: 24px;
animation: bounce 1s ease-in-out infinite;
}
.winner-announcement::after {
content: '🏆';
position: absolute;
top: 6px;
right: 20px;
font-size: 24px;
animation: bounce 1s ease-in-out infinite 0.5s;
}
.winner-announcement h3 {
margin: 0;
font-size: 1.5rem;
color: #2c3e50;
font-size: 1.8rem;
color: #222;
text-align: center;
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 900;
}
@keyframes celebrationPulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 8px 32px rgba(255, 152, 0, 0.3);
}
50% {
transform: scale(1.02);
box-shadow: 0 12px 40px rgba(255, 152, 0, 0.4);
}
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
.btn {
flex: 1;

View File

@@ -0,0 +1,63 @@
import { h } from 'preact';
import modalStyles from './Modal.module.css';
import styles from './GameCompletionModal.module.css';
import type { Game } from '../types/game';
interface GameCompletionModalProps {
open: boolean;
game: Game | null;
onConfirm: () => void;
onClose: () => void;
onRematch: () => void;
}
/**
* Modal shown when a game is completed.
*/
const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }: GameCompletionModalProps) => {
if (!open || !game) return null;
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
let maxScore, winners, winnerText;
if (game.forfeitedBy) {
winnerText = `${game.winner} hat gewonnen, da ${game.forfeitedBy} aufgegeben hat.`;
} else {
maxScore = Math.max(...scores);
winners = playerNames.filter((name, idx) => scores[idx] === maxScore);
winnerText = winners.length > 1
? `Unentschieden zwischen ${winners.join(' und ')}`
: `${winners[0]} hat gewonnen!`;
}
return (
<div id="game-completion-modal" className={modalStyles['modal'] + ' ' + modalStyles['show']} role="dialog" aria-modal="true" aria-labelledby="completion-modal-title">
<div className={modalStyles['modal-content']}>
<div className={modalStyles['modal-header']}>
<span className={modalStyles['modal-title']} id="completion-modal-title">Spiel beendet</span>
<button className={modalStyles['close-button']} onClick={onClose} aria-label="Schließen">×</button>
</div>
<div className={modalStyles['modal-body']}>
<div className={styles['final-scores']}>
{playerNames.map((name, idx) => (
<div className={styles['final-score']} key={name + idx}>
<span className={styles['player-name']}>{name}</span>
<span className={styles['score']}>{scores[idx]}</span>
</div>
))}
</div>
<div className={styles['winner-announcement']}><h3>{winnerText}</h3></div>
</div>
<div className={modalStyles['modal-footer']}>
<button className={styles['btn'] + ' ' + styles['btn--warning']} onClick={onConfirm} aria-label="Bestätigen">Bestätigen</button>
<button className={styles['btn'] + ' ' + styles['btn--primary']} onClick={onRematch} aria-label="Rematch">Rematch</button>
<button className={styles['btn']} onClick={onClose} aria-label="Abbrechen">Abbrechen</button>
</div>
</div>
</div>
);
};
export default GameCompletionModal;

View File

@@ -1,71 +0,0 @@
import { h } from 'preact';
import styles from './GameDetail.module.css';
import GameDetail141 from './GameDetail141.jsx';
/**
* Game detail view for a single game.
* @param {object} props
* @param {object} props.game
* @param {Function} props.onFinishGame
* @param {Function} props.onUpdateScore
* @param {Function} props.onUpdateGame
* @param {Function} props.onUndo
* @param {Function} props.onForfeit
* @param {Function} props.onBack
* @returns {import('preact').VNode|null}
*/
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }) => {
if (!game) return null;
if (game.gameType === '14/1 endlos') {
return <GameDetail141 game={game} onUpdate={onUpdateGame} onUndo={onUndo} onForfeit={onForfeit} onBack={onBack} />;
}
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) => (
<div
className={styles['player-score'] + (name === 'Fränky' ? ' ' + styles['franky'] : '')}
key={name + idx}
>
<span className={styles['player-name']}>{name}</span>
<span
className={styles['score']}
id={`score${idx + 1}`}
onClick={() => !isCompleted && onUpdateScore(idx + 1, 1)}
aria-label={`Aktueller Punktestand für ${name}: ${scores[idx]}. Klicken zum Erhöhen.`}
>
{scores[idx]}
</span>
<button
className={styles['score-button']}
disabled={isCompleted}
onClick={() => onUpdateScore(idx+1, -1)}
aria-label={`Punkt abziehen für ${name}`}
>-</button>
<button
className={styles['score-button']}
disabled={isCompleted}
onClick={() => onUpdateScore(idx+1, 1)}
aria-label={`Punkt hinzufügen für ${name}`}
>+</button>
</div>
))}
</div>
<div className={styles['game-detail-controls']}>
<button className="btn" onClick={onBack} aria-label="Zurück zur Liste">Zurück zur Liste</button>
<button className="btn" disabled={isCompleted} onClick={onFinishGame} aria-label={isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}>{isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}</button>
</div>
</div>
);
};
export default GameDetail;

View File

@@ -44,12 +44,20 @@
.player-score {
flex: 1;
text-align: center;
padding: 20px;
border-radius: 16px;
padding: 30px 20px;
border-radius: 20px;
display: flex;
flex-direction: column;
min-height: 0;
margin: 0 8px;
position: relative;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
transition: all 0.3s ease;
}
.player-score:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0,0,0,0.3);
}
.player-score:first-child {
background-color: #43a047;
@@ -61,21 +69,69 @@
background-color: #333;
}
.player-name {
font-size: 24px;
margin-bottom: 10px;
font-size: 28px;
font-weight: 700;
margin-bottom: 15px;
color: #fff;
text-shadow: 0 2px 8px rgba(0,0,0,0.4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
letter-spacing: 1px;
}
.progress-bar {
width: 100%;
height: 8px;
background: rgba(255,255,255,0.2);
border-radius: 4px;
margin: 10px 0;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #fff 0%, #f0f0f0 100%);
border-radius: 4px;
transition: width 0.5s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.game-status {
position: absolute;
top: 10px;
right: 10px;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.game-status.active {
background: #4caf50;
color: #fff;
}
.game-status.completed {
background: #ff9800;
color: #222;
}
.score {
font-size: 16vh;
font-weight: bold;
margin: 10px 0 20px 0;
font-size: 20vh;
font-weight: 900;
margin: 20px 0 30px 0;
line-height: 1;
color: #fff;
text-shadow: 0 2px 8px rgba(0,0,0,0.4);
text-shadow: 0 4px 16px rgba(0,0,0,0.6);
text-align: center;
display: block;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.score-buttons {
display: flex;
@@ -84,16 +140,42 @@
margin-top: auto;
}
.score-button {
background-color: #333;
color: white;
background: linear-gradient(135deg, #ff9800 0%, #ffa726 100%);
color: #222;
border: none;
border-radius: 5px;
padding: 10px 20px;
font-size: 18px;
border-radius: 50%;
padding: 0;
font-size: 2.5rem;
font-weight: 900;
cursor: pointer;
transition: background-color 0.2s;
min-width: 80px;
margin-bottom: 8px;
transition: all 0.3s ease;
width: 80px;
height: 80px;
margin: 0 8px;
box-shadow: 0 4px 16px rgba(255, 152, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
}
.score-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 152, 0, 0.4);
background: linear-gradient(135deg, #ffa726 0%, #ffb74d 100%);
}
.score-button:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(255, 152, 0, 0.3);
}
.score-button:disabled {
background: #666;
color: #999;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.game-controls {
display: flex;
@@ -128,8 +210,29 @@
color: #ff8c00; /* Example color */
}
.active-player {
border: 2px solid #4caf50;
box-shadow: 0 0 10px #4caf50;
position: relative;
border: 4px solid #ff9800;
box-shadow: 0 0 32px 8px #ff9800, 0 0 0 8px rgba(255,152,0,0.15);
background: linear-gradient(90deg, #ff9800 0 8px, rgba(255,152,0,0.15) 8px 100%);
color: #222 !important;
animation: activePulse 1.2s infinite alternate;
z-index: 2;
}
.active-player .player-name, .active-player .score {
color: #222 !important;
text-shadow: 0 2px 8px rgba(255,255,255,0.25);
}
.active-player::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 8px;
background: #ff9800;
border-radius: 8px 0 0 8px;
}
@keyframes activePulse {
0% { box-shadow: 0 0 32px 8px #ff9800, 0 0 0 8px rgba(255,152,0,0.15); }
100% { box-shadow: 0 0 48px 16px #ff9800, 0 0 0 16px rgba(255,152,0,0.22); }
}
.turn-indicator {
margin: 20px 0;
@@ -273,4 +376,81 @@
.log-entry:last-child {
border-bottom: none;
}
.game-log-table-container {
margin: 2.5rem auto 0 auto;
max-width: 100vw;
width: 100%;
overflow-x: auto;
background: #181818;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0,0,0,0.12);
padding: 1.5rem 1rem;
}
.game-log-table {
width: 100%;
border-collapse: collapse;
font-size: 1.05rem;
background: #222;
color: #fff;
}
.game-log-table th, .game-log-table td {
border: 1px solid #444;
padding: 0.5rem 0.7rem;
text-align: center;
}
.game-log-table th {
background: #333;
font-weight: 700;
font-size: 1.1rem;
}
.game-log-table tr:nth-child(even) td {
background: #232323;
}
.game-log-table tr:nth-child(odd) td {
background: #181818;
}
.game-log-table .log-player-col {
background: #222;
color: #ff9800;
font-size: 1.15rem;
border-bottom: 2px solid #ff9800;
}
.turn-change-controls {
display: flex;
justify-content: center;
margin: 2.5rem 0 1.5rem 0;
}
.turn-change-btn {
background: #ff9800;
color: #222;
font-size: 2.2rem;
font-weight: 900;
padding: 2rem 4rem;
border: none;
border-radius: 18px;
box-shadow: 0 4px 32px rgba(255,152,0,0.18), 0 2px 8px rgba(0,0,0,0.12);
cursor: pointer;
transition: background 0.2s, color 0.2s, box-shadow 0.2s;
letter-spacing: 1px;
margin: 0 auto;
display: block;
}
.turn-change-btn:hover, .turn-change-btn:focus {
background: #ffa726;
color: #111;
box-shadow: 0 6px 40px rgba(255,152,0,0.28), 0 2px 8px rgba(0,0,0,0.18);
}
.pending-foul-info {
margin-left: 1.5rem;
font-size: 1.2rem;
color: #ffc107;
font-weight: 700;
}
.selected {
outline: 3px solid #ff9800 !important;
background: #fff3e0 !important;
color: #222 !important;
}

View File

@@ -0,0 +1,120 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from './GameDetail.module.css';
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) => {
if (!game) return null;
const handleScoreUpdate = (playerIndex: number, change: number) => {
onUpdateScore(playerIndex, change);
// Silent update; toast notifications removed
};
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}
{(() => {
const order = (game as any).breakOrder as number[] | undefined;
const breakerIdx = (game as any).currentBreakerIdx as number | undefined;
if (order && typeof breakerIdx === 'number' && order[breakerIdx] === idx + 1) {
return <span title="Break" aria-label="Break" style={{ display: 'inline-block', width: '1em', height: '1em', borderRadius: '50%', background: '#fff', marginLeft: 6, verticalAlign: 'middle' }} />;
}
return null;
})()}
</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={() => (onUndo ? onUndo() : undefined)}
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();
}}
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>
</div>
);
};
export default GameDetail;

View File

@@ -1,208 +0,0 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from './GameDetail.module.css';
import modalStyles from './PlayerSelectModal.module.css';
const StartingPlayerModal = ({ players, onSelect, onCancel }) => (
<div className={modalStyles.modalOverlay}>
<div className={modalStyles.modalContent} onClick={e => e.stopPropagation()}>
<div className={modalStyles.modalHeader}>
<h3>Welcher Spieler fängt an?</h3>
{/* A cancel button isn't strictly needed if a choice is mandatory */}
</div>
<div className={modalStyles.playerList}>
{players.map((player, index) => (
<button key={player.name} className={modalStyles.playerItem} onClick={() => onSelect(index)}>
{player.name}
</button>
))}
</div>
</div>
</div>
);
const GameLog = ({ log }) => {
if (!log || log.length === 0) {
return null;
}
return (
<div className={styles['game-log']}>
<h4 className={styles['log-title']}>Game Log</h4>
<ul className={styles['log-list']}>
{log.slice().reverse().map((entry, index) => (
<li key={index} className={styles['log-entry']}>
{entry.type === 'rerack' && `Re-Rack (+${entry.ballsAdded} balls).`}
{entry.foul && `Foul by ${entry.player}: ${entry.foul} (${entry.totalDeduction} pts).`}
{entry.ballsPotted !== undefined && `${entry.player}: ${entry.ballsPotted} balls potted. Score: ${entry.newScore}`}
</li>
))}
</ul>
</div>
);
};
const GameDetail141 = ({ game, onUpdate, onUndo, onForfeit, onBack }) => {
const handleSelectStartingPlayer = (playerIndex) => {
onUpdate({
...game,
currentPlayer: playerIndex,
});
};
// If no player is selected yet, show the modal
if (game.currentPlayer === null || game.currentPlayer === undefined) {
return <StartingPlayerModal players={game.players} onSelect={handleSelectStartingPlayer} />;
}
const currentPlayer = game.players[game.currentPlayer];
const handleTurnEnd = (remainingBalls, foulPoints = 0) => {
if (remainingBalls > game.ballsOnTable) {
console.error("Cannot leave more balls than are on the table.");
return;
}
const ballsPotted = game.ballsOnTable - remainingBalls;
const newScore = currentPlayer.score + ballsPotted - foulPoints;
const updatedPlayers = game.players.map((p, index) =>
index === game.currentPlayer ? { ...p, score: newScore, consecutiveFouls: 0 } : p
);
const nextPlayer = (game.currentPlayer + 1) % game.players.length;
onUpdate({
...game,
players: updatedPlayers,
ballsOnTable: remainingBalls,
currentPlayer: nextPlayer,
log: [...(game.log || []), { type: 'turn', player: currentPlayer.name, ballsPotted, foulPoints, newScore, ballsOnTable: remainingBalls }],
});
};
const handleFoul = (foulType) => {
let foulPoints = 0;
let penalty = 0;
const newConsecutiveFouls = (currentPlayer.consecutiveFouls || 0) + 1;
if (foulType === 'standard') {
foulPoints = 1;
} else if (foulType === 'break') {
foulPoints = 2;
}
if (newConsecutiveFouls === 3) {
penalty = 15;
}
const totalDeduction = foulPoints + penalty;
const newScore = currentPlayer.score - totalDeduction;
const updatedPlayers = game.players.map((p, index) => {
if (index === game.currentPlayer) {
return {
...p,
score: newScore,
consecutiveFouls: newConsecutiveFouls === 3 ? 0 : newConsecutiveFouls,
};
}
return p;
});
const nextPlayer = (game.currentPlayer + 1) % game.players.length;
onUpdate({
...game,
players: updatedPlayers,
currentPlayer: nextPlayer,
log: [
...(game.log || []),
{
type: 'foul',
player: currentPlayer.name,
foul: foulType,
foulPoints,
penalty,
totalDeduction,
newScore,
consecutiveFouls: newConsecutiveFouls
},
],
});
};
const handleReRack = (ballsToAdd) => {
const newBallsOnTable = game.ballsOnTable + ballsToAdd;
onUpdate({
...game,
ballsOnTable: newBallsOnTable,
log: [...(game.log || []), { type: 'rerack', player: currentPlayer.name, ballsAdded: ballsToAdd, ballsOnTable: newBallsOnTable }],
});
};
return (
<div className={styles['game-detail']}>
<div className={styles['game-title']}>
14/1 endlos | Race to {game.raceTo}
</div>
<div className={styles['scores-container']}>
{game.players.map((p, idx) => (
<div
className={`${styles['player-score']} ${idx === game.currentPlayer ? styles['active-player'] : ''}`}
key={p.name}
>
<span className={styles['player-name']}>{p.name}</span>
<span className={styles['score']}>{p.score}</span>
{p.consecutiveFouls > 0 && (
<span className={`${styles['foul-indicator']} ${p.consecutiveFouls === 2 ? styles['foul-warning'] : ''}`}>
Fouls: {p.consecutiveFouls}
</span>
)}
</div>
))}
</div>
<div className={styles['turn-indicator']}>
Aktueller Spieler: <strong>{currentPlayer.name}</strong> ({game.ballsOnTable} Bälle auf dem Tisch)
</div>
<div className={styles['potted-balls-container']}>
<p className={styles['potted-balls-header']}>Bälle am Ende der Aufnahme:</p>
<div className={styles['potted-balls-grid']}>
{Array.from({ length: 16 }, (_, i) => i).map(num => (
<button
key={num}
onClick={() => handleTurnEnd(num)}
disabled={num > game.ballsOnTable}
className={styles['potted-ball-btn']}
>
{num}
</button>
))}
</div>
</div>
<div className={styles['rerack-controls']}>
<button onClick={() => handleReRack(14)} className={styles['rerack-btn']}>+14 Re-Rack</button>
<button onClick={() => handleReRack(15)} className={styles['rerack-btn']}>+15 Re-Rack</button>
</div>
<div className={styles['foul-controls']}>
<button onClick={() => handleFoul('standard')} className={styles['foul-btn']}>Standard Foul (-1)</button>
<button onClick={() => handleFoul('break')} className={styles['foul-btn']}>Break Foul (-2)</button>
</div>
<GameLog log={game.log} />
<div className={styles['game-detail-controls']}>
<button className="btn" onClick={onUndo} disabled={!game.undoStack || game.undoStack.length === 0}>Undo</button>
<button className="btn btn-danger" onClick={onForfeit}>Forfeit</button>
<button className="btn" onClick={onBack}>Zurück zur Liste</button>
</div>
</div>
);
};
export default GameDetail141;

View File

@@ -1,62 +0,0 @@
import { h } from 'preact';
import styles from './GameList.module.css';
/**
* List of games with filter and delete options.
* @param {object} props
* @param {object[]} props.games
* @param {string} props.filter
* @param {Function} props.setFilter
* @param {Function} props.onShowGameDetail
* @param {Function} props.onDeleteGame
* @returns {import('preact').VNode}
*/
const GameList = ({ games, filter = 'all', setFilter, onShowGameDetail, onDeleteGame }) => {
// Filter and sort games
const filteredGames = games
.filter(game => {
if (filter === 'active') return game.status === 'active';
if (filter === 'completed') return game.status === 'completed';
return true;
})
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return (
<div className={styles['game-list'] + ' ' + styles['games-container']}>
<div className={styles['filter-buttons']}>
<button className={styles['filter-button'] + (filter === 'all' ? ' ' + styles['active'] : '')} onClick={() => setFilter('all')} aria-label="Alle Spiele anzeigen">Alle</button>
<button className={styles['filter-button'] + (filter === 'active' ? ' ' + styles['active'] : '')} onClick={() => setFilter('active')} aria-label="Nur aktive Spiele anzeigen">Aktiv</button>
<button className={styles['filter-button'] + (filter === 'completed' ? ' ' + styles['active'] : '')} onClick={() => setFilter('completed')} aria-label="Nur abgeschlossene Spiele anzeigen">Abgeschlossen</button>
</div>
{filteredGames.length === 0 ? (
<div className={styles['empty-state']}>Keine Spiele vorhanden</div>
) : (
filteredGames.map(game => {
const playerNames = game.player3
? `${game.player1} vs ${game.player2} vs ${game.player3}`
: `${game.player1} vs ${game.player2}`;
const scores = game.player3
? `${game.score1} - ${game.score2} - ${game.score3}`
: `${game.score1} - ${game.score2}`;
return (
<div
className={
styles['game-item'] + ' ' + (game.status === 'completed' ? styles['completed'] : styles['active'])
}
key={game.id}
>
<div className={styles['game-info']} onClick={() => onShowGameDetail(game.id)} role="button" tabIndex={0} aria-label={`Details für Spiel ${playerNames}`}>
<div className={styles['game-type']}>{game.gameType}{game.raceTo ? ` | ${game.raceTo}` : ''}</div>
<div className={styles['player-names']}>{playerNames}</div>
<div className={styles['game-scores']}>{scores}</div>
</div>
<button className={styles['delete-button']} onClick={() => onDeleteGame(game.id)} aria-label={`Spiel löschen: ${playerNames}`}></button>
</div>
);
})
)}
</div>
);
};
export default GameList;

View File

@@ -1,135 +1,277 @@
/* GameList-specific styles only. Shared utility classes are now in global CSS. */
/* GameList-specific styles using design system tokens */
.screen.active {
display: block;
opacity: 1;
transform: translateX(0);
position: relative;
}
.screen-content {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 20px;
padding: var(--space-lg);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.screen-title {
font-size: 24px;
margin-bottom: 20px;
font-size: var(--font-size-xxl);
margin-bottom: var(--space-lg);
font-weight: 700;
}
.game-list {
width: 100%;
flex: 1;
overflow-y: auto;
}
/* Filter buttons with improved symmetry */
.filter-buttons {
display: flex;
gap: 8px;
margin: 24px 0 16px 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-sm);
margin: var(--space-lg) 0 var(--space-md) 0;
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.filter-button {
flex: 1;
background: #333;
color: #fff;
background: var(--color-secondary);
color: var(--color-text);
border: none;
border-radius: 0;
font-size: 1.2rem;
padding: 18px 0;
font-size: var(--font-size-base);
padding: var(--space-md) 0;
cursor: pointer;
font-weight: 500;
transition: background 0.2s, color 0.2s;
transition: all var(--transition-base);
min-height: var(--touch-target-comfortable);
display: flex;
align-items: center;
justify-content: center;
}
.filter-button:hover {
background: var(--color-secondary-hover);
transform: translateY(-1px);
}
.filter-button.active {
background: #4CAF50;
color: #fff;
background: var(--color-primary);
color: white;
box-shadow: var(--shadow-md);
}
/* Games container with improved spacing */
.games-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 24px;
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-top: var(--space-lg);
}
/* Game item with better symmetry and spacing */
.game-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
border-radius: 0.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: transform 0.1s ease, background-color 0.2s ease;
cursor: pointer;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--space-md);
padding: var(--space-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: all var(--transition-base);
cursor: pointer;
background: var(--color-surface);
border: 1px solid var(--color-border);
}
.game-item:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--color-primary);
}
.game-item.active {
background: #1e4620;
background: var(--color-success);
border-color: var(--color-success);
}
.game-item.completed {
background: #333;
opacity: 0.85;
background: var(--color-surface);
opacity: 0.8;
border-color: var(--color-border);
}
/* Game info with improved layout */
.game-info {
flex: 1;
display: flex;
align-items: center;
gap: 2rem;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--space-lg);
width: 100%;
cursor: pointer;
}
.game-type {
font-weight: bold;
font-size: 1.5rem;
min-width: 8rem;
color: #fff;
font-weight: 700;
font-size: var(--font-size-lg);
color: var(--color-text);
white-space: nowrap;
min-width: 120px;
text-align: center;
background: var(--color-secondary);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
}
.player-names {
color: #fff;
font-size: 1.5rem;
min-width: 16rem;
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 500;
text-align: center;
flex: 1;
}
.game-scores {
font-size: 2rem;
font-weight: bold;
min-width: 6rem;
text-align: center;
color: #fff;
font-size: var(--font-size-xl);
font-weight: 700;
text-align: center;
color: var(--color-primary);
min-width: 120px;
background: var(--color-background);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
}
/* Delete button with improved touch target */
.delete-button {
width: 3rem;
height: 3rem;
border: none;
background: #ff4444;
color: white;
border-radius: 50%;
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
margin-left: 1rem;
transition: background-color 0.2s;
position: relative;
width: var(--touch-target-comfortable);
height: var(--touch-target-comfortable);
border: none;
background: var(--color-danger);
color: white;
border-radius: 50%;
font-size: var(--font-size-lg);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-base);
cursor: pointer;
box-shadow: var(--shadow-sm);
}
.delete-button::before {
content: '\1F5D1'; /* 🗑️ */
font-size: 1.5rem;
content: '🗑️';
font-size: var(--font-size-lg);
}
.delete-button:hover {
background: #cc0000;
background: #cc0000;
transform: scale(1.05);
box-shadow: var(--shadow-md);
}
.delete-button:active {
transform: scale(0.95);
transform: scale(0.95);
}
/* Empty state styling */
.empty-state {
text-align: center;
padding: 2rem;
color: #666;
font-size: 1.5rem;
text-align: center;
padding: var(--space-xxl);
color: var(--color-text-muted);
font-size: var(--font-size-lg);
background: var(--color-surface);
border-radius: var(--radius-lg);
border: 2px dashed var(--color-border);
}
/* Page header */
.page-header {
font-size: 2rem;
font-weight: 700;
color: #fff;
background: #181818;
padding: 24px 0 16px 0;
margin-bottom: 8px;
text-align: left;
width: 100%;
letter-spacing: 0.5px;
font-size: var(--font-size-xxxl);
font-weight: 700;
color: var(--color-text);
background: var(--color-surface);
padding: var(--space-lg) 0 var(--space-md) 0;
margin-bottom: var(--space-sm);
text-align: left;
width: 100%;
letter-spacing: 0.5px;
border-radius: var(--radius-lg);
}
/* Tablet-specific improvements */
@media (min-width: 768px) and (max-width: 1024px) {
.screen-content {
padding: var(--space-xl);
}
.filter-buttons {
gap: var(--space-md);
margin: var(--space-xl) 0 var(--space-lg) 0;
}
.filter-button {
font-size: var(--font-size-lg);
padding: var(--space-lg) 0;
min-height: var(--touch-target-comfortable);
}
.game-item {
padding: var(--space-xl);
gap: var(--space-lg);
}
.game-info {
gap: var(--space-xl);
}
.game-type {
font-size: var(--font-size-xl);
min-width: 150px;
padding: var(--space-md) var(--space-lg);
}
.player-names {
font-size: var(--font-size-xl);
}
.game-scores {
font-size: var(--font-size-xxl);
min-width: 150px;
padding: var(--space-md) var(--space-lg);
}
.delete-button {
width: 64px;
height: 64px;
font-size: var(--font-size-xl);
}
.empty-state {
font-size: var(--font-size-xl);
padding: var(--space-xxl) var(--space-xl);
}
}
/* Mobile adjustments */
@media (max-width: 767px) {
.screen-content {
padding: var(--space-md);
}
.game-info {
grid-template-columns: 1fr;
gap: var(--space-md);
text-align: center;
}
.game-type,
.game-scores {
min-width: auto;
width: 100%;
}
}

127
src/components/GameList.tsx Normal file
View File

@@ -0,0 +1,127 @@
import { h } from 'preact';
import { Card } from './ui/Card';
import { Button } from './ui/Button';
import styles from './GameList.module.css';
import type { Game, GameFilter, StandardGame } from '../types/game';
interface GameListProps {
games: Game[];
filter: GameFilter;
setFilter: (filter: GameFilter) => void;
onShowGameDetail: (gameId: number) => void;
onDeleteGame: (gameId: number) => void;
}
export default function GameList({
games,
filter = 'all',
setFilter,
onShowGameDetail,
onDeleteGame
}: GameListProps) {
const filteredGames = games
.filter(game => {
if (filter === 'active') return game.status === 'active';
if (filter === 'completed') return game.status === 'completed';
return true;
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const getPlayerNames = (game: Game): string => {
if ('players' in game) {
return game.players.map(p => p.name).join(' vs ');
} else {
const standardGame = game as StandardGame;
return standardGame.player3
? `${standardGame.player1} vs ${standardGame.player2} vs ${standardGame.player3}`
: `${standardGame.player1} vs ${standardGame.player2}`;
}
};
const getScores = (game: Game): string => {
if ('players' in game) {
return game.players.map(p => p.score).join(' - ');
} else {
const standardGame = game as StandardGame;
return standardGame.player3
? `${standardGame.score1} - ${standardGame.score2} - ${standardGame.score3}`
: `${standardGame.score1} - ${standardGame.score2}`;
}
};
const filterButtons = [
{ key: 'all' as const, label: 'Alle', ariaLabel: 'Alle Spiele anzeigen' },
{ key: 'active' as const, label: 'Aktiv', ariaLabel: 'Nur aktive Spiele anzeigen' },
{ key: 'completed' as const, label: 'Abgeschlossen', ariaLabel: 'Nur abgeschlossene Spiele anzeigen' },
];
return (
<div className={styles['game-list'] + ' ' + styles['games-container']}>
<div className={styles['filter-buttons']}>
{filterButtons.map(({ key, label, ariaLabel }) => (
<Button
key={key}
variant={filter === key ? 'primary' : 'secondary'}
size="small"
onClick={() => setFilter(key)}
aria-label={ariaLabel}
>
{label}
</Button>
))}
</div>
{filteredGames.length === 0 ? (
<div className={styles['empty-state']}>Keine Spiele vorhanden</div>
) : (
filteredGames.map(game => {
const playerNames = getPlayerNames(game);
const scores = getScores(game);
return (
<Card
key={game.id}
variant="elevated"
className={
styles['game-item'] + ' ' + (game.status === 'completed' ? styles['completed'] : styles['active'])
}
>
<div
className={styles['game-info']}
onClick={() => onShowGameDetail(game.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onShowGameDetail(game.id);
}
}}
role="button"
tabIndex={0}
aria-label={`Details für Spiel ${playerNames}`}
aria-describedby={`game-${game.id}-description`}
>
<div className={styles['game-type']}>
{game.gameType}{game.raceTo ? ` | ${game.raceTo}` : ''}
</div>
<div className={styles['player-names']}>{playerNames}</div>
<div className={styles['game-scores']}>{scores}</div>
<div id={`game-${game.id}-description`} className="sr-only">
{game.gameType} Spiel zwischen {playerNames} mit dem Stand {scores}.
{game.status === 'completed' ? 'Abgeschlossen' : 'Aktiv'}
</div>
</div>
<Button
variant="danger"
size="small"
onClick={() => onDeleteGame(game.id)}
aria-label={`Spiel löschen: ${playerNames}`}
>
🗑
</Button>
</Card>
);
})
)}
</div>
);
}

View File

@@ -1,17 +1,18 @@
import { h } from 'preact';
import styles from './Modal.module.css';
interface ModalProps {
open: boolean;
title: string;
message: string;
onCancel: () => void;
onConfirm: () => void;
}
/**
* Generic modal dialog for confirmation.
* @param {object} props
* @param {boolean} props.open
* @param {string} props.title
* @param {string} props.message
* @param {Function} props.onCancel
* @param {Function} props.onConfirm
* @returns {import('preact').VNode|null}
*/
const Modal = ({ open, title, message, onCancel, onConfirm }) => {
const Modal = ({ open, title, message, onCancel, onConfirm }: ModalProps) => {
if (!open) return null;
return (
<div className={styles['modal'] + ' ' + styles['show']} role="dialog" aria-modal="true" aria-labelledby="modal-title">

View File

@@ -8,270 +8,380 @@
display: none;
opacity: 0;
transform: translateX(100%);
transition: transform 0.3s ease, opacity 0.3s ease;
transition: transform var(--transition-slow), opacity var(--transition-slow);
}
.screen-content {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 20px;
padding: var(--space-lg);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.screen-title {
font-size: 2rem;
font-size: var(--font-size-xxl);
font-weight: 700;
color: #fff;
margin-bottom: 32px;
color: var(--color-text);
margin-bottom: var(--space-xl);
letter-spacing: 0.5px;
text-align: center;
}
.player-inputs {
display: flex;
flex-direction: column;
gap: 24px;
gap: var(--space-lg);
width: 100%;
margin-bottom: 32px;
margin-bottom: var(--space-xl);
}
.player-input {
background: #222;
border-radius: 8px;
padding: 20px 16px 16px 16px;
margin-bottom: 0;
box-shadow: 0 1px 4px rgba(0,0,0,0.12);
background: var(--color-background);
border-radius: var(--radius-md);
padding: var(--space-lg);
border: 2px solid var(--color-border);
transition: border-color var(--transition-base);
position: relative;
}
.player-input:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
.player-input label {
display: block;
margin-bottom: 12px;
color: #ccc;
font-size: 1.2rem;
margin-bottom: var(--space-md);
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 600;
}
.name-input-container {
display: flex;
gap: 12px;
gap: var(--space-md);
position: relative;
}
.name-input {
flex: 2;
padding: 14px 12px;
border: 2px solid #333;
background: #2a2a2a;
color: white;
font-size: 1.1rem;
min-height: 44px;
border-radius: 0 6px 6px 0;
flex: 1;
padding: var(--space-md);
border: 2px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--font-size-base);
min-height: var(--touch-target-comfortable);
border-radius: var(--radius-md);
transition: all var(--transition-base);
}
.name-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
.game-settings {
margin-top: 0;
width: 100%;
margin-bottom: 32px;
margin-bottom: var(--space-xl);
}
.setting-group {
margin-bottom: 20px;
margin-bottom: var(--space-lg);
}
.setting-group label {
display: block;
margin-bottom: 10px;
color: #ccc;
font-size: 1.1rem;
margin-bottom: var(--space-md);
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 600;
}
.setting-group select, .setting-group input {
width: 100%;
padding: 14px 12px;
border: 2px solid #333;
background: #2a2a2a;
color: white;
font-size: 1.1rem;
min-height: 44px;
border-radius: 6px;
padding: var(--space-md);
border: 2px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--font-size-base);
min-height: var(--touch-target-comfortable);
border-radius: var(--radius-md);
transition: border-color var(--transition-base);
}
.setting-group input:focus, .setting-group select:focus {
outline: none;
border-color: #666;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
.validation-error {
color: #f44336;
background: #2a2a2a;
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
font-size: 1.1rem;
color: var(--color-danger);
background: var(--color-surface);
border: 1px solid var(--color-danger);
border-radius: var(--radius-md);
padding: var(--space-md);
margin-bottom: var(--space-md);
font-size: var(--font-size-base);
text-align: center;
font-weight: 500;
}
.new-game-form {
width: 100%;
max-width: 700px;
margin: 32px auto 0 auto;
background: #181818;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0,0,0,0.4);
padding: 32px 24px 24px 24px;
max-width: 600px;
margin: var(--space-xl) auto 0 auto;
background: var(--color-surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
padding: var(--space-xl) var(--space-lg) var(--space-lg) var(--space-lg);
display: flex;
flex-direction: column;
gap: 0;
gap: var(--space-lg);
border: 1px solid var(--color-border);
}
.progress-indicator {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-bottom: 16px;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.progress-dot {
width: 14px;
height: 14px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #444;
background: var(--color-border);
opacity: 0.4;
transition: background 0.2s, opacity 0.2s;
transition: all var(--transition-base);
position: relative;
}
.progress-dot.active {
background: #fff;
background: var(--color-primary);
opacity: 1;
transform: scale(1.2);
box-shadow: 0 0 0 4px var(--color-primary-light);
}
.quick-pick-btn {
min-width: 80px;
min-height: 44px;
font-size: 1.1rem;
border-radius: 8px;
background: #333;
color: #fff;
border: none;
min-height: var(--touch-target-comfortable);
font-size: var(--font-size-base);
border-radius: var(--radius-md);
background: var(--color-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
cursor: pointer;
margin-bottom: 8px;
transition: background 0.2s;
padding: var(--space-sm) var(--space-md);
transition: all var(--transition-base);
font-weight: 500;
}
.quick-pick-btn:active, .quick-pick-btn:focus {
background: #555;
.quick-pick-btn:hover, .quick-pick-btn:focus {
background: var(--color-secondary-hover);
border-color: var(--color-primary);
transform: translateY(-1px);
outline: none;
}
.arrow-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 48px;
margin-top: var(--space-xxl);
width: 100%;
gap: var(--space-lg);
}
.arrow-btn {
font-size: 48px;
width: 80px;
height: 80px;
border-radius: 50%;
background: #222;
color: #fff;
border: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
background: var(--color-secondary);
color: var(--color-text);
border: 2px solid var(--color-border);
box-shadow: var(--shadow-md);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, color 0.2s;
transition: all var(--transition-base);
font-weight: bold;
}
.arrow-btn:active, .arrow-btn:focus {
background: #444;
color: #fff;
.arrow-btn:hover, .arrow-btn:focus {
background: var(--color-secondary-hover);
border-color: var(--color-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
outline: none;
}
.arrow-btn:active {
transform: translateY(0);
}
.clear-input-btn {
position: absolute;
right: 8px;
right: var(--space-sm);
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 24px;
color: #aaa;
padding: 0;
font-size: var(--font-size-xl);
color: var(--color-text-muted);
padding: var(--space-xs);
z-index: 2;
transition: color var(--transition-base);
border-radius: var(--radius-sm);
min-height: var(--touch-target-min);
min-width: var(--touch-target-min);
display: flex;
align-items: center;
justify-content: center;
}
.clear-input-btn:active, .clear-input-btn:focus {
color: #fff;
.clear-input-btn:hover, .clear-input-btn:focus {
color: var(--color-text);
background: var(--color-secondary);
outline: none;
}
.game-type-selection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
gap: var(--space-md);
width: 100%;
margin: 16px 0;
margin: var(--space-md) 0;
}
.game-type-btn {
background: #2a2a2a;
border: 2px solid #333;
color: #fff;
font-size: 1.4rem;
background: var(--color-background);
border: 2px solid var(--color-border);
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 600;
padding: 24px;
border-radius: 8px;
padding: var(--space-xl);
border-radius: var(--radius-md);
cursor: pointer;
text-align: center;
transition: background 0.2s, border-color 0.2s;
transition: all var(--transition-base);
min-height: var(--touch-target-comfortable);
}
.game-type-btn:hover {
background: #333;
border-color: #555;
background: var(--color-surface);
border-color: var(--color-primary);
transform: translateY(-1px);
}
.game-type-btn.selected {
background: #4a4a4a;
border-color: #777;
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
box-shadow: var(--shadow-md);
}
.race-to-selection {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: var(--space-md);
width: 100%;
margin: 16px 0;
margin: var(--space-md) 0;
}
.race-to-btn {
background: #2a2a2a;
border: 2px solid #333;
color: #fff;
font-size: 1.3rem;
background: var(--color-background);
border: 2px solid var(--color-border);
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 600;
padding: 16px 8px;
border-radius: 8px;
padding: var(--space-lg) var(--space-sm);
border-radius: var(--radius-md);
cursor: pointer;
text-align: center;
transition: background 0.2s, border-color 0.2s;
min-height: 70px;
transition: all var(--transition-base);
min-height: 80px;
display: flex;
align-items: center;
justify-content: center;
}
.race-to-btn:hover {
background: #333;
border-color: #555;
background: var(--color-surface);
border-color: var(--color-primary);
transform: translateY(-1px);
}
.race-to-btn.selected {
background: #4a4a4a;
border-color: #777;
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
box-shadow: var(--shadow-md);
}
.custom-race-to {
display: flex;
gap: 12px;
margin-top: 24px;
gap: var(--space-md);
margin-top: var(--space-lg);
align-items: center;
}
.custom-race-to input {
flex-grow: 1;
}
.custom-race-to .arrow-btn {
width: 60px;
height: 60px;
font-size: 32px;
flex-shrink: 0;
}
.endlos-container {
width: 100%;
margin-bottom: 12px;
}
.endlos-btn {
width: 100%;
}
.player1-input.player-input {
border-color: var(--color-success);
background: linear-gradient(135deg, var(--color-success) 0%, rgba(76, 175, 80, 0.1) 100%);
}
.player2-input.player-input {
border-color: #1565c0;
background: linear-gradient(135deg, #1565c0 0%, rgba(21, 101, 192, 0.1) 100%);
}
.player3-input.player-input {
border-color: var(--color-secondary);
background: linear-gradient(135deg, var(--color-secondary) 0%, rgba(51, 51, 51, 0.1) 100%);
}
.player1-input.player-input input,
.player2-input.player-input input,
.player3-input.player-input input {
background: #fff;
color: #222;
border: 1px solid #ccc;
}
@media (min-width: 768px) and (max-width: 1024px) {
.screen-content {
padding: var(--space-xl);
}
.new-game-form {
max-width: 700px;
padding: var(--space-xxl) var(--space-xl) var(--space-xl) var(--space-xl);
}
.screen-title {
font-size: var(--font-size-xxxl);
}
.arrow-btn {
width: 100px;
height: 100px;
font-size: 56px;
}
.game-type-btn,
.race-to-btn {
padding: var(--space-xl);
font-size: var(--font-size-xl);
min-height: var(--touch-target-comfortable);
}
.quick-pick-btn {
min-height: var(--touch-target-comfortable);
font-size: var(--font-size-lg);
padding: var(--space-md) var(--space-lg);
}
}
@media (max-width: 767px) {
.screen-content {
padding: var(--space-md);
}
.new-game-form {
margin: var(--space-lg) auto 0 auto;
padding: var(--space-lg);
}
.game-type-selection {
grid-template-columns: 1fr;
}
.race-to-selection {
grid-template-columns: repeat(3, 1fr);
}
.arrow-nav {
gap: var(--space-md);
}
.arrow-btn {
width: 70px;
height: 70px;
font-size: 40px;
}
}

View File

@@ -2,8 +2,27 @@ import { h } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import styles from './NewGame.module.css';
import modalStyles from './PlayerSelectModal.module.css';
import {
UI_CONSTANTS,
WIZARD_STEPS,
GAME_TYPE_OPTIONS,
RACE_TO_QUICK_PICKS,
RACE_TO_DEFAULT,
RACE_TO_INFINITY,
ERROR_MESSAGES,
ARIA_LABELS,
FORM_CONFIG,
ERROR_STYLES
} from '../utils/constants';
import type { BreakRule } from '../types/game';
const PlayerSelectModal = ({ players, onSelect, onClose }) => (
interface PlayerSelectModalProps {
players: string[];
onSelect: (player: string) => void;
onClose: () => void;
}
export const PlayerSelectModal = ({ players, onSelect, onClose }: PlayerSelectModalProps) => (
<div className={modalStyles.modalOverlay} onClick={onClose}>
<div className={modalStyles.modalContent} onClick={e => e.stopPropagation()}>
<div className={modalStyles.modalHeader}>
@@ -21,21 +40,33 @@ const PlayerSelectModal = ({ players, onSelect, onClose }) => (
</div>
);
interface PlayerStepProps {
playerNameHistory: string[];
onNext: (name: string) => void;
onCancel: () => void;
initialValue?: string;
}
/**
* Player 1 input step for multi-step game creation wizard.
* @param {object} props
* @param {string[]} props.playerNameHistory
* @param {Function} props.onNext
* @param {Function} props.onCancel
* @param {string} [props.initialValue]
* @returns {import('preact').VNode}
*/
const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) => {
const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
const [player1, setPlayer1] = useState(initialValue);
const [error, setError] = useState(null);
const [error, setError] = useState<string | null>(null);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const [isModalOpen, setIsModalOpen] = useState(false);
const inputRef = useRef(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const el = inputRef.current;
if (el) {
el.focus();
const end = el.value.length;
try {
el.setSelectionRange(end, end);
} catch {}
}
}, []);
useEffect(() => {
if (!player1) {
@@ -49,22 +80,41 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
}
}, [player1, playerNameHistory]);
const handleSubmit = (e) => {
const handleSubmit = (e: Event) => {
e.preventDefault();
if (!player1.trim()) {
setError('Bitte Namen für Spieler 1 eingeben');
const trimmedName = player1.trim();
if (!trimmedName) {
setError(ERROR_MESSAGES.PLAYER1_REQUIRED);
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.setAttribute('aria-invalid', 'true');
}
return;
}
if (trimmedName.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
setError(`Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`);
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.setAttribute('aria-invalid', 'true');
}
return;
}
setError(null);
onNext(player1.trim());
if (inputRef.current) {
inputRef.current.setAttribute('aria-invalid', 'false');
}
onNext(trimmedName);
};
const handleQuickPick = (name) => {
const handleQuickPick = (name: string) => {
setError(null);
onNext(name);
};
const handleModalSelect = (name) => {
const handleModalSelect = (name: string) => {
setIsModalOpen(false);
handleQuickPick(name);
};
@@ -77,29 +127,64 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 1 Eingabe" autoComplete="off">
<div className={styles['screen-title']}>Neues Spiel Schritt 1/5</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<div className={styles['screen-title']}>Name Spieler 1</div>
<div className={styles['progress-indicator']} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_MEDIUM }}>
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
</div>
<div className={styles['player-input']} style={{ marginBottom: 32, position: 'relative' }}>
<label htmlFor="player1-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 1</label>
<div className={styles['player-input'] + ' ' + styles['player1-input']} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_LARGE, position: 'relative' }}>
<label htmlFor="player1-input" style={{ fontSize: UI_CONSTANTS.LABEL_FONT_SIZE, fontWeight: 600 }}>Spieler 1</label>
<div style={{ position: 'relative', width: '100%' }}>
<input
id="player1-input"
className={styles['name-input']}
placeholder="Name Spieler 1"
value={player1}
onInput={e => setPlayer1(e.target.value)}
autoFocus
onInput={(e: Event) => {
const target = e.target as HTMLInputElement;
const value = target.value;
setPlayer1(value);
// Real-time validation feedback
if (value.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
setError(`Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`);
target.setAttribute('aria-invalid', 'true');
} else if (value.trim() && error) {
setError(null);
target.setAttribute('aria-invalid', 'false');
}
}}
autoComplete="off"
aria-label="Name Spieler 1"
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
aria-describedby="player1-help"
style={{
fontSize: UI_CONSTANTS.INPUT_FONT_SIZE,
minHeight: UI_CONSTANTS.INPUT_MIN_HEIGHT,
marginTop: 12,
marginBottom: 12,
width: '100%',
paddingRight: UI_CONSTANTS.INPUT_PADDING_RIGHT
}}
ref={inputRef}
/>
<div id="player1-help" className="sr-only">
Geben Sie den Namen für Spieler 1 ein. Maximal {FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen erlaubt.
</div>
{player1.length > FORM_CONFIG.CHARACTER_COUNT_WARNING_THRESHOLD && (
<div style={{
fontSize: '0.875rem',
color: player1.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH ? '#f44336' : '#ff9800',
marginTop: '4px',
textAlign: 'right'
}}>
{player1.length}/{FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen
</div>
)}
{player1 && (
<button
type="button"
@@ -128,25 +213,41 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
</div>
{filteredNames.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
{filteredNames.slice(0, 10).map((name, idx) => (
{filteredNames.slice(0, UI_CONSTANTS.MAX_QUICK_PICKS).map((name, idx) => (
<button
type="button"
key={name + idx}
className={styles['quick-pick-btn']}
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
style={{
fontSize: UI_CONSTANTS.QUICK_PICK_FONT_SIZE,
padding: UI_CONSTANTS.QUICK_PICK_PADDING,
borderRadius: 8,
background: '#333',
color: '#fff',
border: 'none',
cursor: 'pointer'
}}
onClick={() => handleQuickPick(name)}
aria-label={`Schnellauswahl: ${name}`}
aria-label={ARIA_LABELS.QUICK_PICK(name)}
>
{name}
</button>
))}
{playerNameHistory.length > 10 && (
{playerNameHistory.length > UI_CONSTANTS.MAX_QUICK_PICKS && (
<button
type="button"
className={styles['quick-pick-btn']}
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
style={{
fontSize: UI_CONSTANTS.QUICK_PICK_FONT_SIZE,
padding: UI_CONSTANTS.QUICK_PICK_PADDING,
borderRadius: 8,
background: '#333',
color: '#fff',
border: 'none',
cursor: 'pointer'
}}
onClick={() => setIsModalOpen(true)}
aria-label="Weitere Spieler anzeigen"
aria-label={ARIA_LABELS.SHOW_MORE_PLAYERS}
>
...
</button>
@@ -154,7 +255,20 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
</div>
)}
</div>
{error && <div className={styles['validation-error']} style={{ marginBottom: 16 }}>{error}</div>}
{error && (
<div
className={styles['validation-error']}
style={{
marginBottom: 16,
...ERROR_STYLES.CONTAINER
}}
role="alert"
aria-live="polite"
>
<span style={ERROR_STYLES.ICON}></span>
{error}
</div>
)}
{isModalOpen && (
<PlayerSelectModal
players={playerNameHistory}
@@ -189,18 +303,23 @@ const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
/**
* Player 2 input step for multi-step game creation wizard.
* @param {object} props
* @param {string[]} props.playerNameHistory
* @param {Function} props.onNext
* @param {Function} props.onCancel
* @param {string} [props.initialValue]
* @returns {import('preact').VNode}
*/
const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) => {
const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
const [player2, setPlayer2] = useState(initialValue);
const [error, setError] = useState(null);
const [error, setError] = useState<string | null>(null);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const inputRef = useRef(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const el = inputRef.current;
if (el) {
el.focus();
const end = el.value.length;
try {
el.setSelectionRange(end, end);
} catch {}
}
}, []);
useEffect(() => {
if (!player2) {
@@ -214,7 +333,7 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
}
}, [player2, playerNameHistory]);
const handleSubmit = (e) => {
const handleSubmit = (e: Event) => {
e.preventDefault();
if (!player2.trim()) {
setError('Bitte Namen für Spieler 2 eingeben');
@@ -224,7 +343,7 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
onNext(player2.trim());
};
const handleQuickPick = (name) => {
const handleQuickPick = (name: string) => {
setError(null);
onNext(name);
};
@@ -237,15 +356,17 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 2 Eingabe" autoComplete="off">
<div className={styles['screen-title']}>Neues Spiel Schritt 2/5</div>
<div className={styles['screen-title']}>Name Spieler 2</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
</div>
<div className={styles['player-input']} style={{ marginBottom: 32, position: 'relative' }}>
<div className={styles['player-input'] + ' ' + styles['player2-input']} style={{ marginBottom: 32, position: 'relative' }}>
<label htmlFor="player2-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 2</label>
<div style={{ position: 'relative', width: '100%' }}>
<input
@@ -253,8 +374,10 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
className={styles['name-input']}
placeholder="Name Spieler 2"
value={player2}
onInput={e => setPlayer2(e.target.value)}
autoFocus
onInput={(e: Event) => {
const target = e.target as HTMLInputElement;
setPlayer2(target.value);
}}
autoComplete="off"
aria-label="Name Spieler 2"
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
@@ -328,17 +451,22 @@ const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
/**
* Player 3 input step for multi-step game creation wizard.
* @param {object} props
* @param {string[]} props.playerNameHistory
* @param {Function} props.onNext
* @param {Function} props.onCancel
* @param {string} [props.initialValue]
* @returns {import('preact').VNode}
*/
const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }) => {
const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
const [player3, setPlayer3] = useState(initialValue);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const inputRef = useRef(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const el = inputRef.current;
if (el) {
el.focus();
const end = el.value.length;
try {
el.setSelectionRange(end, end);
} catch {}
}
}, []);
useEffect(() => {
if (!player3) {
@@ -352,13 +480,13 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
}
}, [player3, playerNameHistory]);
const handleSubmit = (e) => {
const handleSubmit = (e: Event) => {
e.preventDefault();
// Player 3 is optional, so always allow submission
onNext(player3.trim());
};
const handleQuickPick = (name) => {
const handleQuickPick = (name: string) => {
onNext(name);
};
@@ -367,22 +495,24 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
if (inputRef.current) inputRef.current.focus();
};
const handleSkip = (e) => {
const handleSkip = (e: Event) => {
e.preventDefault();
onNext('');
};
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 3 Eingabe" autoComplete="off">
<div className={styles['screen-title']}>Neues Spiel Schritt 3/5</div>
<div className={styles['screen-title']}>Name Spieler 3 (optional)</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
</div>
<div className={styles['player-input']} style={{ marginBottom: 32, position: 'relative' }}>
<div className={styles['player-input'] + ' ' + styles['player3-input']} style={{ marginBottom: 32, position: 'relative' }}>
<label htmlFor="player3-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 3 (optional)</label>
<div style={{ position: 'relative', width: '100%' }}>
<input
@@ -390,8 +520,10 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
className={styles['name-input']}
placeholder="Name Spieler 3 (optional)"
value={player3}
onInput={e => setPlayer3(e.target.value)}
autoFocus
onInput={(e: Event) => {
const target = e.target as HTMLInputElement;
setPlayer3(target.value);
}}
autoComplete="off"
aria-label="Name Spieler 3"
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
@@ -472,23 +604,26 @@ const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' })
);
};
interface GameTypeStepProps {
onNext: (type: string) => void;
onCancel: () => void;
initialValue?: string;
}
/**
* Game Type selection step for multi-step game creation wizard.
* @param {object} props
* @param {Function} props.onNext
* @param {Function} props.onCancel
* @param {string} [props.initialValue]
* @returns {import('preact').VNode}
*/
const GameTypeStep = ({ onNext, onCancel, initialValue = '' }) => {
const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeStepProps) => {
const [gameType, setGameType] = useState(initialValue);
const gameTypes = ['8-Ball', '9-Ball', '10-Ball', '14/1 endlos'];
const gameTypes = ['8-Ball', '9-Ball', '10-Ball'];
const handleSelect = (selectedType) => {
const handleSelect = (selectedType: string) => {
setGameType(selectedType);
// Auto-advance to next step on selection
onNext(selectedType);
};
const handleSubmit = (e) => {
const handleSubmit = (e: Event) => {
e.preventDefault();
if (gameType) {
onNext(gameType);
@@ -497,13 +632,15 @@ const GameTypeStep = ({ onNext, onCancel, initialValue = '' }) => {
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spielart auswählen">
<div className={styles['screen-title']}>Neues Spiel Schritt 4/5</div>
<div className={styles['screen-title']}>Spielart auswählen</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
</div>
<div className={styles['game-type-selection']}>
{gameTypes.map(type => (
@@ -554,45 +691,67 @@ const GameTypeStep = ({ onNext, onCancel, initialValue = '' }) => {
);
};
interface RaceToStepProps {
onNext: (raceTo: string | number) => void;
onCancel: () => void;
initialValue?: string | number;
gameType?: string;
}
/**
* Race To selection step for multi-step game creation wizard.
* @param {object} props
* @param {Function} props.onNext
* @param {Function} props.onCancel
* @param {string|number} [props.initialValue]
* @returns {import('preact').VNode}
*/
const RaceToStep = ({ onNext, onCancel, initialValue = '' }) => {
const [raceTo, setRaceTo] = useState(initialValue);
const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: RaceToStepProps) => {
const quickPicks = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const defaultValue = 5;
const [raceTo, setRaceTo] = useState<string | number>(initialValue !== '' ? initialValue : defaultValue);
const handleQuickPick = (value) => {
setRaceTo(value);
useEffect(() => {
if ((initialValue === '' || initialValue === undefined) && raceTo !== defaultValue) {
setRaceTo(defaultValue);
}
if (initialValue !== '' && initialValue !== undefined && initialValue !== raceTo) {
setRaceTo(initialValue);
}
}, [gameType, initialValue, defaultValue]);
const handleQuickPick = (value: number) => {
// For endlos (endless) games, use Infinity to prevent automatic completion
const selected = value === 0 ? 'Infinity' : value;
setRaceTo(selected);
// Auto-advance to the next step (finalize) when a quick pick is chosen
const raceToValue = selected === 'Infinity' ? Infinity : (parseInt(String(selected), 10) || 0);
onNext(raceToValue);
};
const handleInputChange = (e) => {
setRaceTo(e.target.value);
const handleInputChange = (e: Event) => {
const target = e.target as HTMLInputElement;
setRaceTo(target.value);
};
const handleSubmit = (e) => {
const handleSubmit = (e: Event) => {
e.preventDefault();
onNext(parseInt(raceTo, 10) || 0);
// Handle Infinity for endlos games, otherwise parse as integer
const raceToValue = raceTo === 'Infinity' ? Infinity : (parseInt(String(raceTo), 10) || 0);
onNext(raceToValue);
};
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Race To auswählen">
<div className={styles['screen-title']}>Neues Spiel Schritt 5/5</div>
<div className={styles['screen-title']}>Race To auswählen</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
</div>
<div className={styles['endlos-container']}>
<button
type="button"
className={`${styles['race-to-btn']} ${styles['endlos-btn']} ${raceTo === 0 ? styles.selected : ''}`}
className={`${styles['race-to-btn']} ${styles['endlos-btn']} ${raceTo === 'Infinity' ? styles.selected : ''}`}
onClick={() => handleQuickPick(0)}
>
Endlos
@@ -603,7 +762,7 @@ const RaceToStep = ({ onNext, onCancel, initialValue = '' }) => {
<button
key={value}
type="button"
className={`${styles['race-to-btn']} ${parseInt(raceTo, 10) === value ? styles.selected : ''}`}
className={`${styles['race-to-btn']} ${parseInt(String(raceTo), 10) === value ? styles.selected : ''}`}
onClick={() => handleQuickPick(value)}
>
{value}
@@ -634,15 +793,143 @@ const RaceToStep = ({ onNext, onCancel, initialValue = '' }) => {
<button
type="submit"
className={styles['arrow-btn']}
aria-label="Fertigstellen"
aria-label="Weiter"
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
{/* Unicode checkmark */}
&#10003;
{/* Unicode right arrow */}
&#8594;
</button>
</div>
</form>
);
};
export { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep };
interface BreakRuleStepProps {
onNext: (rule: BreakRule) => void;
onCancel: () => void;
initialValue?: BreakRule | '';
}
const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }: BreakRuleStepProps) => {
const [rule, setRule] = useState<BreakRule>(initialValue as BreakRule);
return (
<form className={styles['new-game-form']} aria-label="Break-Regel wählen">
<div className={styles['screen-title']}>Break-Regel wählen</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 12 }}>
{[
{ key: 'winnerbreak', label: 'Winnerbreak' },
{ key: 'wechselbreak', label: 'Wechselbreak' },
].map(opt => (
<button
key={opt.key}
type="button"
className={`${styles['quick-pick-btn']} ${rule === (opt.key as BreakRule) ? styles['selected'] : ''}`}
onClick={() => { setRule(opt.key as BreakRule); onNext(opt.key as BreakRule); }}
aria-label={`Break-Regel wählen: ${opt.label}`}
>
{opt.label}
</button>
))}
</div>
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<button type="button" className={styles['arrow-btn']} aria-label="Zurück" onClick={onCancel} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
&#8592;
</button>
<button type="button" className={styles['arrow-btn']} aria-label="Weiter" onClick={() => onNext(rule)} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
&#8594;
</button>
</div>
</form>
);
};
interface BreakOrderStepProps {
players: string[];
rule: BreakRule;
onNext: (first: number, second?: number) => void;
onCancel: () => void;
initialFirst?: number;
initialSecond?: number;
}
const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst = 1, initialSecond }: BreakOrderStepProps) => {
const playerCount = players.filter(Boolean).length;
const [first, setFirst] = useState<number>(initialFirst);
const [second, setSecond] = useState<number>(initialSecond ?? (playerCount >= 2 ? 2 : 1));
const handleFirst = (idx: number) => {
setFirst(idx);
// Auto-advance cases: winnerbreak (any players) OR wechselbreak with 2 players
if (rule === 'winnerbreak' || (rule === 'wechselbreak' && playerCount === 2)) {
onNext(idx);
}
};
const handleSecond = (idx: number) => {
setSecond(idx);
onNext(first, idx);
};
return (
<form className={styles['new-game-form']} aria-label="Break-Reihenfolge wählen">
<div className={styles['screen-title']}>Wer hat den ersten Anstoss?</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
</div>
<div style={{ marginBottom: 16, fontWeight: 600 }}>Wer hat den ersten Anstoss?</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{players.filter(Boolean).map((name, idx) => (
<button key={`first-${idx}`} type="button" className={styles['quick-pick-btn']} onClick={() => handleFirst(idx + 1)} aria-label={`Zuerst: ${name}`}>{name}</button>
))}
</div>
{rule === 'wechselbreak' && playerCount === 3 && (
<>
<div style={{ marginTop: 24, marginBottom: 16, fontWeight: 600 }}>Wer bricht als Zweites?</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{players.filter(Boolean).map((name, idx) => (
<button key={`second-${idx}`} type="button" className={styles['quick-pick-btn']} onClick={() => handleSecond(idx + 1)} aria-label={`Zweites Break: ${name}`}>{name}</button>
))}
</div>
</>
)}
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<button type="button" className={styles['arrow-btn']} aria-label="Zurück" onClick={onCancel} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
&#8592;
</button>
<button
type="button"
className={styles['arrow-btn']}
aria-label="Weiter"
onClick={() => {
if (rule === 'wechselbreak' && playerCount === 3) {
handleSecond(second);
} else {
onNext(first);
}
}}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
&#8594;
</button>
</div>
</form>
);
};
export { Player1Step, Player2Step, Player3Step, GameTypeStep, BreakRuleStep, BreakOrderStep, RaceToStep };

View File

@@ -0,0 +1 @@
import { h } from "preact";

View File

@@ -1,15 +1,16 @@
import { h } from 'preact';
import styles from './Modal.module.css';
interface ValidationModalProps {
open: boolean;
message: string;
onClose: () => void;
}
/**
* Modal for displaying validation errors.
* @param {object} props
* @param {boolean} props.open
* @param {string} props.message
* @param {Function} props.onClose
* @returns {import('preact').VNode|null}
*/
const ValidationModal = ({ open, message, onClose }) => {
const ValidationModal = ({ open, message, onClose }: ValidationModalProps) => {
if (!open) return null;
return (
<div className={styles['modal'] + ' ' + styles['show']} id="validation-modal" role="alertdialog" aria-modal="true" aria-labelledby="validation-modal-title">

View File

@@ -0,0 +1,46 @@
import { h } from 'preact';
import { Screen } from '../ui/Layout';
import GameDetail from '../GameDetail';
import type { Game, EndlosGame } from '../../types/game';
interface GameDetailScreenProps {
game?: Game;
onUpdateScore: (player: number, change: number) => void;
onFinishGame: () => void;
onUpdateGame: (game: EndlosGame) => void;
onUndo: () => void;
onForfeit: () => void;
onBack: () => void;
}
export default function GameDetailScreen({
game,
onUpdateScore,
onFinishGame,
onUpdateGame,
onUndo,
onForfeit,
onBack,
}: GameDetailScreenProps) {
if (!game) {
return (
<Screen>
<div>Game not found</div>
</Screen>
);
}
return (
<Screen>
<GameDetail
game={game}
onUpdateScore={onUpdateScore}
onFinishGame={onFinishGame}
onUpdateGame={onUpdateGame}
onUndo={onUndo}
onForfeit={onForfeit}
onBack={onBack}
/>
</Screen>
);
}

View File

@@ -0,0 +1,45 @@
import { h } from 'preact';
import { Button } from '../ui/Button';
import { Screen } from '../ui/Layout';
import GameList from '../GameList';
import type { Game, GameFilter } from '../../types/game';
interface GameListScreenProps {
games: Game[];
filter: GameFilter;
onFilterChange: (filter: GameFilter) => void;
onShowGameDetail: (gameId: number) => void;
onDeleteGame: (gameId: number) => void;
onShowNewGame: () => void;
}
export default function GameListScreen({
games,
filter,
onFilterChange,
onShowGameDetail,
onDeleteGame,
onShowNewGame,
}: GameListScreenProps) {
return (
<Screen>
<Button
variant="primary"
size="large"
onClick={onShowNewGame}
aria-label="Neues Spiel starten"
style={{ width: '100%', marginBottom: '24px' }}
>
+ Neues Spiel
</Button>
<GameList
games={games}
filter={filter}
onShowGameDetail={onShowGameDetail}
onDeleteGame={onDeleteGame}
setFilter={onFilterChange}
/>
</Screen>
);
}

View File

@@ -0,0 +1,166 @@
import { h } from 'preact';
import { Screen } from '../ui/Layout';
import { Player1Step, Player2Step, Player3Step, GameTypeStep, BreakRuleStep, BreakOrderStep, RaceToStep } from '../NewGame';
import type { NewGameStep, NewGameData } from '../../types/game';
interface NewGameScreenProps {
step: NewGameStep;
data: NewGameData;
playerHistory: string[];
onStepChange: (step: NewGameStep) => void;
onDataChange: (data: Partial<NewGameData>) => void;
onCreateGame: (data: NewGameData) => void;
onCancel: () => void;
onShowValidation: (message: string) => void;
}
export default function NewGameScreen({
step,
data,
playerHistory,
onStepChange,
onDataChange,
onCreateGame,
onCancel,
onShowValidation,
}: NewGameScreenProps) {
const handlePlayer1Next = (name: string) => {
onDataChange({ player1: name });
onStepChange('player2');
};
const handlePlayer2Next = (name: string) => {
onDataChange({ player2: name });
onStepChange('player3');
};
const handlePlayer3Next = (name: string) => {
onDataChange({ player3: name });
onStepChange('gameType');
};
const handleGameTypeNext = (type: string) => {
onDataChange({
gameType: type as any, // Type assertion for now, could be improved with proper validation
raceTo: '8'
});
onStepChange('raceTo');
};
const handleBreakRuleNext = (rule: 'winnerbreak' | 'wechselbreak') => {
onDataChange({ breakRule: rule });
try { localStorage.setItem('lastBreakRule', rule); } catch {}
onStepChange('breakOrder');
};
const handleBreakOrderNext = (first: number, second?: number) => {
const finalData = { ...data, breakFirst: first, breakSecond: second ?? '' } as any;
onDataChange({ breakFirst: first, breakSecond: second ?? '' });
try {
localStorage.setItem('lastBreakFirst', String(first));
if (second) localStorage.setItem('lastBreakSecond', String(second));
} catch {}
onCreateGame(finalData as any);
};
const handleRaceToNext = (raceTo: string) => {
const finalData = { ...data, raceTo };
// After race to, go to break rule selection
onDataChange({ raceTo });
onStepChange('breakRule');
};
const handleStepBack = () => {
switch (step) {
case 'player2':
onStepChange('player1');
break;
case 'player3':
onStepChange('player2');
break;
case 'gameType':
onStepChange('player3');
break;
case 'raceTo':
onStepChange('gameType');
break;
case 'breakRule':
onStepChange('raceTo');
break;
case 'breakOrder':
onStepChange('breakRule');
break;
default:
onCancel();
}
};
return (
<Screen>
{step === 'player1' && (
<Player1Step
playerNameHistory={playerHistory}
onNext={handlePlayer1Next}
onCancel={onCancel}
initialValue={data.player1}
/>
)}
{step === 'player2' && (
<Player2Step
playerNameHistory={playerHistory}
onNext={handlePlayer2Next}
onCancel={handleStepBack}
initialValue={data.player2}
/>
)}
{step === 'player3' && (
<Player3Step
playerNameHistory={playerHistory}
onNext={handlePlayer3Next}
onCancel={handleStepBack}
initialValue={data.player3}
/>
)}
{step === 'gameType' && (
<GameTypeStep
onNext={handleGameTypeNext}
onCancel={handleStepBack}
initialValue={data.gameType}
/>
)}
{step === 'raceTo' && (
<RaceToStep
onNext={handleRaceToNext}
onCancel={handleStepBack}
initialValue={data.raceTo}
gameType={data.gameType}
/>
)}
{step === 'breakRule' && (
<BreakRuleStep
onNext={handleBreakRuleNext}
onCancel={handleStepBack}
initialValue={(data.breakRule as any) || (typeof window !== 'undefined' ? (localStorage.getItem('lastBreakRule') as any) : 'winnerbreak') || 'winnerbreak'}
/>
)}
{step === 'breakOrder' && (
<BreakOrderStep
players={[data.player1, data.player2, data.player3]}
rule={(data.breakRule as any) || 'winnerbreak'}
onNext={handleBreakOrderNext}
onCancel={handleStepBack}
initialFirst={(typeof window !== 'undefined' && localStorage.getItem('lastBreakFirst')) ? parseInt(localStorage.getItem('lastBreakFirst')!, 10) : 1}
initialSecond={(typeof window !== 'undefined' && localStorage.getItem('lastBreakSecond')) ? parseInt(localStorage.getItem('lastBreakSecond')!, 10) : undefined}
/>
)}
</Screen>
);
}

View File

@@ -0,0 +1,134 @@
/* Use CSS custom properties from global design system */
.button {
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-weight: 600;
transition: var(--transition-base);
touch-action: manipulation;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
text-decoration: none;
user-select: none;
font-family: inherit;
position: relative;
overflow: hidden;
}
.button::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(45deg, transparent 30%, rgba(255, 255, 255, 0.1) 50%, transparent 70%);
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.button:hover::before {
transform: translateX(100%);
}
.button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.button:active {
transform: translateY(1px);
}
/* Variants */
.primary {
background: var(--color-primary);
color: white;
box-shadow: var(--shadow-md);
}
.primary:hover:not(.disabled) {
background: var(--color-primary-hover);
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
}
.secondary {
background: var(--color-secondary);
color: var(--color-text);
box-shadow: var(--shadow-sm);
}
.secondary:hover:not(.disabled) {
background: var(--color-secondary-hover);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.danger {
background: var(--color-danger);
color: white;
box-shadow: var(--shadow-md);
}
.danger:hover:not(.disabled) {
background: #ef5350;
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
}
/* Sizes with improved touch targets for tablets */
.small {
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
min-height: var(--touch-target-min);
border-radius: var(--radius-sm);
}
.medium {
padding: var(--space-md) var(--space-lg);
font-size: var(--font-size-base);
min-height: var(--touch-target-comfortable);
border-radius: var(--radius-md);
}
.large {
padding: var(--space-lg) var(--space-xl);
font-size: var(--font-size-lg);
min-height: 56px;
border-radius: var(--radius-lg);
}
/* Tablet-specific size adjustments */
@media (min-width: 768px) and (max-width: 1024px) {
.small {
min-height: var(--touch-target-comfortable);
padding: var(--space-md) var(--space-lg);
font-size: var(--font-size-base);
}
.medium {
min-height: var(--touch-target-comfortable);
padding: var(--space-lg) var(--space-xl);
font-size: var(--font-size-lg);
}
.large {
min-height: 64px;
padding: var(--space-xl) var(--space-xxl);
font-size: var(--font-size-xl);
}
}
/* States */
.disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.disabled:hover::before {
transform: translateX(-100%);
}

View File

@@ -0,0 +1,32 @@
import { h } from 'preact';
import type { ButtonProps } from '../../types/ui';
import styles from './Button.module.css';
export function Button({
variant = 'secondary',
size = 'medium',
disabled = false,
children,
onClick,
'aria-label': ariaLabel,
...rest
}: ButtonProps) {
const classNames = [
styles.button,
styles[variant],
styles[size],
disabled && styles.disabled,
].filter(Boolean).join(' ');
return (
<button
className={classNames}
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
{...rest}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,53 @@
.card {
border-radius: var(--border-radius);
transition: var(--transition);
}
.default {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.elevated {
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08);
}
.outlined {
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.2);
}
.clickable {
cursor: pointer;
border: none;
text-align: left;
width: 100%;
}
.clickable:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-1px);
}
.clickable:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Padding variants */
.padding-none {
padding: 0;
}
.padding-small {
padding: 8px;
}
.padding-medium {
padding: 16px;
}
.padding-large {
padding: 24px;
}

View File

@@ -0,0 +1,34 @@
import { h } from 'preact';
import styles from './Card.module.css';
interface CardProps {
children: any;
variant?: 'default' | 'elevated' | 'outlined';
padding?: 'none' | 'small' | 'medium' | 'large';
className?: string;
onClick?: () => void;
}
export function Card({
children,
variant = 'default',
padding = 'medium',
className = '',
onClick
}: CardProps) {
const classNames = [
styles.card,
styles[variant],
styles[`padding-${padding}`],
onClick && styles.clickable,
className,
].filter(Boolean).join(' ');
const Component = onClick ? 'button' : 'div';
return (
<Component className={classNames} onClick={onClick}>
{children}
</Component>
);
}

View File

@@ -0,0 +1,44 @@
.layout {
min-height: 100vh;
background-color: var(--color-background);
color: var(--color-text);
display: flex;
flex-direction: column;
}
.content {
flex: 1;
max-width: 1200px;
margin: 0 auto;
padding: var(--space-md);
width: 100%;
}
.screen {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Tablet optimizations */
@media (min-width: 768px) and (max-width: 1024px) {
.content {
padding: var(--space-lg) var(--space-xl);
max-width: 900px;
}
}
/* Large tablet and small desktop */
@media (min-width: 1025px) {
.content {
padding: var(--space-xl);
}
}
/* Mobile adjustments */
@media (max-width: 767px) {
.content {
padding: var(--space-sm) var(--space-md);
}
}

View File

@@ -0,0 +1,33 @@
import { h } from 'preact';
import styles from './Layout.module.css';
interface LayoutProps {
children: any;
className?: string;
}
export function Layout({ children, className = '' }: LayoutProps) {
return (
<div className={`${styles.layout} ${className}`}>
<a href="#main-content" className="skip-link">
Zum Hauptinhalt springen
</a>
<div className={styles.content} id="main-content">
{children}
</div>
</div>
);
}
interface ScreenProps {
children: any;
className?: string;
}
export function Screen({ children, className = '' }: ScreenProps) {
return (
<div className={`${styles.screen} ${className}`}>
{children}
</div>
);
}

121
src/hooks/useGameState.ts Normal file
View File

@@ -0,0 +1,121 @@
import { useState, useEffect, useCallback } from 'preact/hooks';
import type { Game, NewGameData, GameFilter } from '../types/game';
import { GameService } from '../services/gameService';
export function useGameState() {
const [games, setGames] = useState<Game[]>([]);
const [filter, setFilter] = useState<GameFilter>('all');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load games from IndexedDB on mount
useEffect(() => {
const loadGames = async () => {
try {
setLoading(true);
setError(null);
const savedGames = await GameService.loadGames();
setGames(savedGames);
} catch (err) {
console.error('Failed to load games:', err);
setError('Failed to load games from storage');
} finally {
setLoading(false);
}
};
loadGames();
}, []);
const addGame = useCallback(async (gameData: NewGameData): Promise<number> => {
try {
const newGame = GameService.createGame(gameData);
await GameService.saveGame(newGame);
setGames(prevGames => [newGame, ...prevGames]);
return newGame.id;
} catch (err) {
console.error('Failed to add game:', err);
setError('Failed to save new game');
throw err;
}
}, []);
const updateGame = useCallback(async (gameId: number, updatedGame: Game) => {
try {
await GameService.saveGame(updatedGame);
setGames(prevGames =>
prevGames.map(game => game.id === gameId ? updatedGame : game)
);
} catch (err) {
console.error('Failed to update game:', err);
setError('Failed to save game changes');
throw err;
}
}, []);
const deleteGame = useCallback(async (gameId: number) => {
try {
await GameService.deleteGame(gameId);
setGames(prevGames => prevGames.filter(game => game.id !== gameId));
} catch (err) {
console.error('Failed to delete game:', err);
setError('Failed to delete game');
throw err;
}
}, []);
const getGameById = useCallback((gameId: number): Game | undefined => {
return games.find(game => game.id === gameId);
}, [games]);
const getFilteredGames = useCallback(async (): Promise<Game[]> => {
try {
return await GameService.getGamesByFilter(filter);
} catch (err) {
console.error('Failed to get filtered games:', err);
setError('Failed to load filtered games');
return games.filter(game => {
if (filter === 'active') return game.status === 'active';
if (filter === 'completed') return game.status === 'completed';
return true;
});
}
}, [filter, games]);
const getPlayerNameHistory = useCallback((): string[] => {
// Extract player names from current games for immediate UI use
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);
}
});
return [...new Set(Object.keys(nameLastUsed))].sort((a, b) => nameLastUsed[b] - nameLastUsed[a]);
}, [games]);
return {
games,
filter,
setFilter,
loading,
error,
addGame,
updateGame,
deleteGame,
getGameById,
getFilteredGames,
getPlayerNameHistory,
};
}

60
src/hooks/useModal.ts Normal file
View File

@@ -0,0 +1,60 @@
import { useState, useCallback } from 'preact/hooks';
import type { ModalState, ValidationState, CompletionModalState } from '../types/ui';
import type { Game } from '../types/game';
export function useModal() {
const [modal, setModal] = useState<ModalState>({ open: false, gameId: null });
const openModal = useCallback((gameId?: number) => {
setModal({ open: true, gameId });
}, []);
const closeModal = useCallback(() => {
setModal({ open: false, gameId: null });
}, []);
return {
modal,
openModal,
closeModal,
};
}
export function useValidationModal() {
const [validation, setValidation] = useState<ValidationState>({ open: false, message: '' });
const showValidation = useCallback((message: string) => {
setValidation({ open: true, message });
}, []);
const closeValidation = useCallback(() => {
setValidation({ open: false, message: '' });
}, []);
return {
validation,
showValidation,
closeValidation,
};
}
export function useCompletionModal() {
const [completionModal, setCompletionModal] = useState<CompletionModalState>({
open: false,
game: null
});
const openCompletionModal = useCallback((game: Game) => {
setCompletionModal({ open: true, game });
}, []);
const closeCompletionModal = useCallback(() => {
setCompletionModal({ open: false, game: null });
}, []);
return {
completionModal,
openCompletionModal,
closeCompletionModal,
};
}

View File

@@ -0,0 +1,82 @@
import { useState, useCallback } from 'preact/hooks';
import type { NewGameStep, NewGameData } from '../types/game';
type Screen = 'game-list' | 'new-game' | 'game-detail';
export function useNavigation() {
const [screen, setScreen] = useState<Screen>('game-list');
const [currentGameId, setCurrentGameId] = useState<number | null>(null);
const showGameList = useCallback(() => {
setScreen('game-list');
setCurrentGameId(null);
}, []);
const showNewGame = useCallback(() => {
setScreen('new-game');
setCurrentGameId(null);
}, []);
const showGameDetail = useCallback((gameId: number) => {
setCurrentGameId(gameId);
setScreen('game-detail');
}, []);
return {
screen,
currentGameId,
showGameList,
showNewGame,
showGameDetail,
};
}
export function useNewGameWizard() {
const [newGameStep, setNewGameStep] = useState<NewGameStep>(null);
const [newGameData, setNewGameData] = useState<NewGameData>({
player1: '',
player2: '',
player3: '',
gameType: '',
raceTo: '',
});
const startWizard = useCallback(() => {
setNewGameStep('player1');
setNewGameData({
player1: '',
player2: '',
player3: '',
gameType: '',
raceTo: '',
});
}, []);
const resetWizard = useCallback(() => {
setNewGameStep(null);
setNewGameData({
player1: '',
player2: '',
player3: '',
gameType: '',
raceTo: '',
});
}, []);
const updateGameData = useCallback((data: Partial<NewGameData>) => {
setNewGameData(prev => ({ ...prev, ...data }));
}, []);
const nextStep = useCallback((step: NewGameStep) => {
setNewGameStep(step);
}, []);
return {
newGameStep,
newGameData,
startWizard,
resetWizard,
updateGameData,
nextStep,
};
}

View File

@@ -0,0 +1,70 @@
---
---
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="interactive-widget=resizes-content">
<title>BSC Score</title>
<style>
body {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.layout-header {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 10vh;
background: #222;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.layout-footer {
position: fixed;
bottom: 0;
left: 0;
width: 100vw;
height: 10vh;
background: #222;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.layout-main {
min-height: 80vh;
margin-top: 10vh;
margin-bottom: 10vh;
width: 100vw;
box-sizing: border-box;
}
</style>
</head>
<body>
<header class="layout-header">
<!-- BUTTONS PLACEHOLDER -->
<div style="width:100%; display:flex; justify-content:center; gap:1rem;">
<button disabled>Button 1</button>
<button disabled>Button 2</button>
<button disabled>Button 3</button>
</div>
</header>
<main class="layout-main">
<slot />
</main>
<footer class="layout-footer">
<!-- INFO PLACEHOLDER -->
<div style="width:100%; text-align:center;">
<span>Informational text goes here. &copy; {new Date().getFullYear()} BSC Score</span>
</div>
</footer>
</body>
</html>

View File

@@ -1,9 +1,38 @@
---
import "../styles/index.css";
import App from "../components/App.jsx";
import BscScoreApp from "../components/BscScoreApp.astro";
import App from "../components/App";
---
<!-- Main entry point for the Pool Scoring App -->
<main class="screen-container">
<App client:only="preact" />
</main>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BSC Score - Pool Scoring App</title>
<meta name="description" content="Professional pool/billiards scoring application for tournaments and casual games">
<!-- Performance optimizations -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- PWA Meta tags -->
<meta name="theme-color" content="#1a1a1a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png">
</head>
<body>
<BscScoreApp>
<!--
Using client:only for the main app since it's highly interactive
and benefits from full client-side rendering
-->
<App client:only="preact" slot="app-content" />
</BscScoreApp>
</body>
</html>

317
src/services/gameService.ts Normal file
View File

@@ -0,0 +1,317 @@
import type { Game, GameType, StandardGame, EndlosGame, NewGameData, BreakRule } from '../types/game';
import { IndexedDBService } from './indexedDBService';
const LOCAL_STORAGE_KEY = 'bscscore_games';
export class GameService {
/**
* Load games from IndexedDB (with localStorage fallback)
*/
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);
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 migrating from localStorage:', error);
// Clear corrupted data
localStorage.removeItem(LOCAL_STORAGE_KEY);
return [];
}
}
/**
* Save a single game to IndexedDB
*/
static async saveGame(game: Game): Promise<void> {
try {
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 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
}
}
/**
* 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,
status: 'active' as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
log: [],
undoStack: [],
};
const standardGame: StandardGame = {
...baseGame,
player1: gameData.player1,
player2: gameData.player2,
score1: 0,
score2: 0,
breakRule: (gameData.breakRule as BreakRule) || 'winnerbreak',
breakOrder: (() => {
// Determine break order from inputs, fallback defaults
const players: number[] = [1, 2];
if (gameData.player3?.trim()) players.push(3);
const first = (typeof gameData.breakFirst === 'number' ? gameData.breakFirst : 1) as number;
const second = (typeof gameData.breakSecond === 'number' ? gameData.breakSecond : (players.includes(2) && first !== 2 ? 2 : (players.includes(3) ? 3 : 2))) as number;
const order = [first];
if (players.length === 2) {
order.push(first === 1 ? 2 : 1);
} else {
// 3 players: add chosen second, then the remaining third
order.push(second);
const third = [1,2,3].find(p => p !== first && p !== second)!;
order.push(third);
}
return order;
})(),
currentBreakerIdx: 0,
};
if (gameData.player3) {
standardGame.player3 = gameData.player3;
standardGame.score3 = 0;
}
return standardGame;
}
/**
* Update a game's score (for standard games)
*/
static updateGameScore(game: StandardGame, player: number, change: number): StandardGame {
const updated = { ...game };
if (player === 1) updated.score1 = Math.max(0, updated.score1 + change);
if (player === 2) updated.score2 = Math.max(0, updated.score2 + change);
if (player === 3 && updated.score3 !== undefined) {
updated.score3 = Math.max(0, updated.score3 + change);
}
// Breaker logic
if (change > 0) {
if ((updated.breakRule || 'winnerbreak') === 'winnerbreak') {
// Winner keeps break: set breaker to this player
const order = updated.breakOrder || [1,2].concat(updated.score3 !== undefined ? [3] : []);
updated.breakOrder = order;
const idx = order.findIndex(p => p === player);
updated.currentBreakerIdx = idx >= 0 ? idx : 0;
} else {
// Wechselbreak: rotate to next
const order = updated.breakOrder || [1,2].concat(updated.score3 !== undefined ? [3] : []);
updated.breakOrder = order;
const curr = typeof updated.currentBreakerIdx === 'number' ? updated.currentBreakerIdx : 0;
updated.currentBreakerIdx = (curr + 1) % order.length;
}
}
updated.updatedAt = new Date().toISOString();
return updated;
}
/**
* Check if a game is completed based on raceTo
*/
static isGameCompleted(game: Game): boolean {
if (game.status === 'completed') return true;
// If raceTo is Infinity, the game never completes automatically
if (game.raceTo === Infinity) return false;
if ('players' in game) {
// EndlosGame
return game.players.some(player => player.score >= game.raceTo);
} else {
// StandardGame
const scores = [game.score1, game.score2, game.score3].filter(score => score !== undefined);
return scores.some(score => score >= game.raceTo);
}
}
/**
* Get the winner of a completed game
*/
static getGameWinner(game: Game): string | null {
if (!this.isGameCompleted(game)) return null;
// If raceTo is Infinity, there's no automatic winner
if (game.raceTo === Infinity) return null;
if ('players' in game) {
// EndlosGame
const winner = game.players.find(player => player.score >= game.raceTo);
return winner?.name || null;
} else {
// StandardGame
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;
}
/**
* Get player name history from IndexedDB
*/
static async getPlayerNameHistory(): Promise<string[]> {
try {
return await IndexedDBService.getPlayerNameHistory();
} catch (error) {
console.error('Error loading player history from IndexedDB:', error);
return [];
}
}
/**
* 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,
};
}
}

View File

@@ -11,76 +11,229 @@
user-select: none;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: Arial, sans-serif;
background-color: #1a1a1a;
color: white;
min-height: 100vh;
overscroll-behavior: none;
}
input, select {
min-height: 44px;
padding: 12px;
font-size: 1.2rem;
/* Design system tokens */
:root {
/* Colors */
--color-primary: #ff9800;
--color-primary-hover: #ffa726;
--color-primary-light: rgba(255, 152, 0, 0.1);
--color-secondary: #333;
--color-secondary-hover: #444;
--color-background: #1a1a1a;
--color-surface: #222;
--color-surface-hover: #2a2a2a;
--color-text: #fff;
--color-text-secondary: #ccc;
--color-text-muted: #999;
--color-border: #333;
--color-success: #4caf50;
--color-danger: #f44336;
--color-warning: #ff9800;
/* Spacing system - 8px base */
--space-xs: 0.25rem; /* 4px */
--space-sm: 0.5rem; /* 8px */
--space-md: 1rem; /* 16px */
--space-lg: 1.5rem; /* 24px */
--space-xl: 2rem; /* 32px */
--space-xxl: 3rem; /* 48px */
/* Typography */
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-lg: 1.125rem; /* 18px */
--font-size-xl: 1.25rem; /* 20px */
--font-size-xxl: 1.5rem; /* 24px */
--font-size-xxxl: 2rem; /* 32px */
/* Touch targets */
--touch-target-min: 44px;
--touch-target-comfortable: 48px;
/* Border radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.16);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.24);
/* Transitions */
--transition-fast: 0.15s ease;
--transition-base: 0.2s ease;
--transition-slow: 0.3s ease;
}
/* Responsive adjustments for fullscreen toggle button */
@media screen and (max-width: 480px) {
.fullscreenToggle {
bottom: 15px;
right: 15px;
width: 40px;
height: 40px;
/* Tablet-specific design tokens */
@media (min-width: 768px) and (max-width: 1024px) {
:root {
--touch-target-min: 48px;
--touch-target-comfortable: 56px;
--space-md: 1.25rem; /* 20px */
--space-lg: 2rem; /* 32px */
--space-xl: 2.5rem; /* 40px */
--font-size-base: 1.125rem; /* 18px */
--font-size-lg: 1.25rem; /* 20px */
--font-size-xl: 1.5rem; /* 24px */
--font-size-xxl: 1.75rem; /* 28px */
--font-size-xxxl: 2.25rem; /* 36px */
}
}
/* Utility button for new game (global, not component-specific) */
.new-game-button {
width: 100%;
background: #222;
color: #fff;
border: none;
border-radius: 0;
font-size: 1.4rem;
font-weight: 600;
padding: 20px 0;
margin-bottom: 16px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
text-align: center;
display: block;
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--color-background);
color: var(--color-text);
min-height: 100vh;
overscroll-behavior: none;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.new-game-button:hover {
background: #333;
/* Improved input styling for better tablet experience */
input, select {
min-height: var(--touch-target-comfortable);
padding: var(--space-md);
font-size: var(--font-size-base);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
transition: border-color var(--transition-base);
}
/* Shared utility classes for buttons and layout */
input:focus, select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
/* Enhanced button styling */
.btn {
flex: 1;
min-width: 100px;
padding: 18px;
color: white;
background: #333;
min-height: var(--touch-target-comfortable);
padding: var(--space-md) var(--space-lg);
color: var(--color-text);
background: var(--color-secondary);
border: none;
border-radius: 6px;
font-size: 1.2rem;
border-radius: var(--radius-md);
font-size: var(--font-size-base);
font-weight: 600;
cursor: pointer;
touch-action: manipulation;
transition: background 0.2s, color 0.2s;
transition: all var(--transition-base);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
text-decoration: none;
user-select: none;
}
.btn:hover {
background: #444;
background: var(--color-secondary-hover);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn--secondary {
background: #333;
color: #fff;
border: 2px solid #555;
}
.btn--secondary:hover {
background: #444;
border-color: #666;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.btn-primary:hover {
background: var(--color-primary-hover);
}
/* Utility button for new game with better spacing */
.new-game-button {
width: 100%;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-lg);
font-size: var(--font-size-xxl);
font-weight: 700;
padding: var(--space-xl) var(--space-lg);
margin-bottom: var(--space-lg);
cursor: pointer;
transition: all var(--transition-base);
text-align: center;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-lg);
letter-spacing: 0.5px;
gap: var(--space-md);
min-height: var(--touch-target-comfortable);
}
.new-game-button::before {
content: '+';
font-size: 2.2rem;
font-weight: 900;
display: inline-block;
line-height: 1;
}
.new-game-button:hover {
background: var(--color-primary-hover);
transform: translateY(-2px);
box-shadow: 0 8px 40px rgba(255, 152, 0, 0.4);
}
.new-game-button:active {
transform: translateY(-1px);
}
/* Navigation buttons with improved spacing */
.nav-buttons {
display: flex;
flex-direction: column;
gap: 12px;
margin: 16px 0 0 0;
gap: var(--space-md);
margin: var(--space-lg) 0 0 0;
}
/* Modal overlay (global, not component-specific) */
/* Tablet-specific improvements */
@media (min-width: 768px) and (max-width: 1024px) {
.nav-buttons {
flex-direction: row;
gap: var(--space-lg);
}
.btn {
flex: 1;
min-height: var(--touch-target-comfortable);
}
.new-game-button {
padding: var(--space-xxl) var(--space-xl);
font-size: var(--font-size-xxxl);
}
}
/* Enhanced modal styling */
.modal {
position: fixed;
top: 0;
@@ -88,12 +241,64 @@ input, select {
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
padding: var(--space-lg);
}
.modal.show {
display: flex;
}
/* Screen reader only content */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Skip link for keyboard navigation */
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: var(--color-primary);
color: white;
padding: 8px;
text-decoration: none;
border-radius: 4px;
z-index: 10000;
}
.skip-link:focus {
top: 6px;
}
/* Responsive fullscreen toggle */
@media screen and (max-width: 480px) {
.fullscreenToggle {
bottom: var(--space-md);
right: var(--space-md);
width: 40px;
height: 40px;
}
}
@media (min-width: 768px) and (max-width: 1024px) {
.fullscreenToggle {
bottom: var(--space-lg);
right: var(--space-lg);
width: 56px;
height: 56px;
}
}

9
src/types/css-modules.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare module '*.module.css' {
const classes: { [key: string]: string };
export default classes;
}
declare module '*.css' {
const content: string;
export default content;
}

74
src/types/game.ts Normal file
View File

@@ -0,0 +1,74 @@
export type GameStatus = 'active' | 'completed';
export type GameType = '8-Ball' | '9-Ball' | '10-Ball';
export type SyncStatus = 'local' | 'synced' | 'pending' | 'conflict';
export interface Player {
name: string;
score: number;
consecutiveFouls?: number;
}
export interface GameAction {
type: 'score_change' | 'game_complete' | 'game_forfeit' | 'undo';
player?: number;
change?: number;
timestamp: string;
description: string;
}
export interface BaseGame {
id: number;
gameType: GameType;
raceTo: number;
status: GameStatus;
createdAt: string;
updatedAt: string;
log: GameAction[];
undoStack: BaseGame[];
// Sync fields for future online functionality
syncStatus?: SyncStatus;
version?: number;
lastModified?: number;
}
export type BreakRule = 'winnerbreak' | 'wechselbreak';
export interface StandardGame extends BaseGame {
player1: string;
player2: string;
player3?: string;
score1: number;
score2: number;
score3?: number;
// Break management
breakRule?: BreakRule; // default winnerbreak for backfill
breakOrder?: number[]; // 1-based player indices, e.g. [1,2] or [1,2,3]
currentBreakerIdx?: number; // index into breakOrder
}
export interface EndlosGame extends BaseGame {
players: Player[];
currentPlayer: number | null;
ballsOnTable: number;
winner?: string;
forfeitedBy?: string;
}
export type Game = StandardGame | EndlosGame;
export interface NewGameData {
player1: string;
player2: string;
player3: string;
gameType: GameType | '';
raceTo: string;
breakRule?: BreakRule | '';
breakFirst?: number | '';
breakSecond?: number | '';
}
export type NewGameStep = 'player1' | 'player2' | 'player3' | 'gameType' | 'breakRule' | 'breakOrder' | 'raceTo' | null;
export type GameFilter = 'all' | 'active' | 'completed';

33
src/types/ui.ts Normal file
View File

@@ -0,0 +1,33 @@
export interface ModalState {
open: boolean;
gameId?: number | null;
}
export interface ValidationState {
open: boolean;
message: string;
}
import type { Game } from './game';
export interface CompletionModalState {
open: boolean;
game: Game | null;
}
export interface AppScreen {
current: 'game-list' | 'new-game' | 'game-detail';
}
export interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
children?: React.ReactNode;
onClick?: () => void;
'aria-label'?: string;
style?: React.CSSProperties;
className?: string;
type?: 'button' | 'submit' | 'reset';
[key: string]: unknown; // Allow additional props
}

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

@@ -0,0 +1,133 @@
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 },
];
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;
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;

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

@@ -0,0 +1,108 @@
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 raceTo is Infinity, progress is always 0 (endless game)
if (game.raceTo === Infinity) return 0;
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 raceTo is Infinity, there's no automatic winner
if (game.raceTo === Infinity) return null;
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
);
}

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

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

@@ -0,0 +1,99 @@
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[] = [];
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);
}
}
} catch (error) {
console.error('Validation error:', error);
errors.push('Ein unerwarteter Validierungsfehler ist aufgetreten');
}
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,
};
}

View File

@@ -9,6 +9,7 @@
],
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
"jsxImportSource": "preact",
"lib": ["ES2020", "DOM", "DOM.Iterable"]
}
}