65 Commits

Author SHA1 Message Date
Frank Schwenk 678977ab23 feat: tablet-first UX overhaul with wizard improvements
- Tablet-first responsive scoreboard: whole player card is tap target
- Per-step wizard backgrounds and live summary panel
- Larger touch targets, safe-area handling, reduced-motion support
- German UI error strings in App.tsx
- Updated SOUL.md and README.md for current architecture
- Cleanup: removed legacy components, .cursor/, UI_IMPROVEMENTS.md
- Renamed package to bscscore, fixed TOTAL_WIZARD_STEPS
2026-09-21 17:59:31 +02:00
Frank Schwenk 3327f6e19e chore(release): 2.1.1
Refs #30

Made-with: Cursor
2026-04-16 13:20:48 +02:00
Frank Schwenk a288405ca8 fix(pwa): allow rotation and request fullscreen
Remove forced portrait orientation and request fullscreen display mode.

Made-with: Cursor
2026-04-16 13:19:27 +02:00
Frank Schwenk 5bdea62a9f feat: add pwa manifest and service worker
Wire up a production service worker and web app manifest with install metadata and icon assets.
Bump project version to 2.1.0 and register the worker from the main page for offline-ready behavior.

Made-with: Cursor
2026-04-14 15:34:10 +02:00
Frank Schwenk 55cba1495f fix: auto-advance on new-game selections
Advance the wizard immediately when selecting game type, break rule, race-to quick picks, and completed break-order choices for a consistent tap-first flow.

Made-with: Cursor
2026-04-14 15:28:52 +02:00
Frank Schwenk ed7c6232c1 refactor: streamline new-game player selection ux
Consolidate new-game wizard steps into reusable components and remove legacy duplicate files.
Replace inline player inputs with a chip-first flow and minimal name-entry modal, while improving compact-screen spacing and step-specific selection behavior.

Made-with: Cursor
2026-04-14 15:22:56 +02:00
Frank Schwenk 5deb38ebb7 test: add landscape viewport matrix keyboard flow coverage
Add Playwright viewport matrix coverage for Android-tablet-like landscape sizes, including reduced-height keyboard simulation during player input steps and wizard progression checks. Refs #30.

Made-with: Cursor
2026-04-04 11:32:59 +02:00
Frank Schwenk 586e5f26bd fix: make wizard progression explicit and consistent
Require explicit Weiter confirmation after selection in game type, race-to, break rule, and break order steps to prevent accidental auto-advance and keep step behavior predictable. Refs #30.

Made-with: Cursor
2026-04-04 11:32:23 +02:00
Frank Schwenk 9bf4c20f11 fix: improve Android tablet keyboard-safe layout
Use dynamic viewport sizing and keyboard-aware spacing to keep new-game inputs and navigation reachable in PWA landscape mode on Android tablets. Refs #30.

Made-with: Cursor
2026-04-04 11:31:31 +02:00
Frank Schwenk f8d895ab21 Add Docker Compose configuration for deployment
- Add compose.yml with nginx service configuration
- Configure Traefik labels for reverse proxy routing
- Set up service to serve static files from ./dist directory
- Configure SSL/TLS with automatic certificate resolution
- Use external Traefik network for integration
- Service accessible at score.bsc-gp.de domain

Refs #30
2025-11-30 20:33:29 +01:00
Frank Schwenk 01123f291d refactor: standardize new game progress ui
- introduce shared progress indicator component for wizard steps
- align layouts and button sizing across new game panels
- update feature exports to surface the new component

Refs #30
2025-11-14 11:07:26 +01:00
Frank Schwenk 8a46a8a019 refactor: extract reusable library
- move reusable domain, data, state, ui code into src/lib
- update host screens to consume new library exports
- document architecture and configure path aliases
- bump astro integration dependencies for compatibility

Refs #30
2025-11-13 10:41:55 +01:00
Frank Schwenk 99be99d120 Make game creation wizard fit viewport without scrolling
Replace scrollable form content with responsive sizing that
automatically scales elements to fit available viewport height.

CSS improvements:
- Disable scrolling: overflow-y:auto → overflow:hidden in form-content
- Implement fluid typography with clamp() for titles, labels, buttons
- Add responsive spacing using clamp() for margins and padding
- Scale progress dots from 10px-16px based on viewport height
- Reduce button dimensions (60px min-width, 36px min-height)
- Enable element shrinking with flex-shrink:1 and min-height:0

Component cleanup:
- Remove auto-focus useEffect from Player1/2/3Step components
- Prevents unwanted layout shifts on wizard mount

Benefits:
- All elements visible without scrolling
- Responsive design scales smoothly across viewport sizes
- Cleaner UX with no scrollbars in form wizard
- Better space utilization on small screens
2025-11-07 14:30:39 +01:00
Frank Schwenk 076d6ced36 Implement fixed viewport with internal scrolling
Restructure app layout to prevent whole-page scrolling. The viewport
is now locked at 100vh with overflow:hidden, and individual content
areas scroll internally.

Architecture changes:
- Lock html/body at 100% height with overflow:hidden
- Fix Layout component to 100vh, Screen to 100% height
- Enable internal scrolling for content areas with flex:1 + overflow-y:auto

New game wizard improvements:
- Split forms into three sections: form-header (fixed), form-content
  (scrollable), form-footer (fixed with arrow navigation)
- Fixes issue where many player names pushed navigation arrows off-screen
- Applied to Player1Step, Player2Step, Player3Step, GameTypeStep

Game list improvements:
- Filter buttons stay fixed at top
- Games container scrolls internally with overflow-y:auto
- "Neues Spiel" button wrapped with flex-shrink:0

Game detail improvements:
- Game controls stay visible while content scrolls

Additional changes:
- Add Playwright test artifact exclusions to .gitignore
- Add Docker build instructions to README.md
- Remove unnecessary setSelectionRange calls from player input steps

Benefits:
- No accidental page scrolling
- Cleaner mobile UX (no address bar show/hide issues)
- Navigation controls always visible
- Predictable, contained scrolling behavior
2025-11-07 14:23:03 +01:00
Frank Schwenk 65aaa92359 Add Playwright E2E testing with recorded workflows
- Add @playwright/test as dev dependency
- Create playwright.config.ts with Chrome-only testing config
- Add npm scripts: test:record, test:e2e, test:replay
- Create 13 test recordings covering:
  - 2-player and 3-player games
  - 8-ball, 9-ball, and 10-ball game types
  - Various race-to values (1, 3, 5, 7, 9) and "endlos" mode
  - Both wechselbreak (alternating) and winnerbreak rules
- Fix Infinity handling in gameService.ts and NewGameScreen.tsx
  - Parse "endlos" and "Infinity" strings as Infinity number
  - Properly serialize Infinity as string in form data
- Increase GameDetail score font size from 20vh to 40vh
- Update README.md with testing documentation:
  - Quick start guide for recording and running tests
  - Move E2E testing from "Future Improvements" (now implemented)
- Add comprehensive tests/recordings/README.md documentation

Purpose: Establishes browser automation testing infrastructure with
real workflow recordings, enabling regression testing and interaction
documentation for all game configuration combinations.
2025-10-31 14:41:36 +01:00
Frank Schwenk 9175d505c2 feat(new-game): highlight selected players in break order step
- Apply selected styling to first/second player buttons in `BreakOrderStep.tsx`
- Mirrors selection UX from `BreakRuleStep.tsx` for visual feedback

Refs #30
2025-10-30 16:26:38 +01:00
Frank Schwenk 64fedd3024 feat(new-game): default Break rule and selected styling
- Default Break rule to 'winnerbreak' in BreakRuleStep
- Ensure selected quick-pick button styling matches Race To buttons
- Simplify Weiter button since rule is always defined

Fixes #30
2025-10-30 16:13:18 +01:00
Frank Schwenk a6a16fdacf feat(game): remove +/- score buttons; tap to increment (Fixes #29)
- Remove plus/minus score controls in GameDetail; tap on score increments by +1
- Keep global Undo control for reversals; maintain keyboard accessibility on score
- No changes to scoring logic beyond UI control removal
2025-10-30 16:02:44 +01:00
Frank Schwenk 2b17027801 refactor(new-game): extract BreakRuleStep and BreakOrderStep
Refs #30

- Add BreakRuleStep.tsx and BreakOrderStep.tsx under src/components/new-game
- Replace inline components with imports in NewGame.tsx
- Structural only; behavior unchanged
2025-10-30 15:23:31 +01:00
Frank Schwenk d6ea0125df refactor(new-game): extract GameTypeStep and RaceToStep
Refs #30

- Add GameTypeStep.tsx and RaceToStep.tsx under src/components/new-game
- Replace inline components with imports in NewGame.tsx
- Pure refactor; behavior unchanged
2025-10-30 15:17:35 +01:00
Frank Schwenk af0ffe8517 refactor(new-game): extract Player2Step and Player3Step modules
Refs #30

- Add Player2Step.tsx and Player3Step.tsx under src/components/new-game
- Replace inline definitions with imports in NewGame.tsx
- No behavior changes
2025-10-30 15:14:01 +01:00
Frank Schwenk f8b461e189 refactor(new-game): extract Player1Step into separate module
Refs #30

- Add src/components/new-game/Player1Step.tsx
- Replace inline Player1Step with import in NewGame.tsx
- Preserve behavior; no UI or logic changes
2025-10-30 15:09:02 +01:00
Frank Schwenk 8aac1d476a refactor(new-game): extract PlayerSelectModal to separate file
Refs #30

- Create src/components/new-game/PlayerSelectModal.tsx
- Replace inline modal with imported component in NewGame.tsx
- No behavior changes; purely structural extraction
2025-10-30 15:05:36 +01:00
Frank Schwenk 892c01d188 chore: point .gitea to issue #30
Refs #30

- Update .gitea to #30
- Prepare repository to associate upcoming refactor commits with the correct issue
2025-10-30 15:03:41 +01:00
Frank Schwenk 26a97e7eaa UX: disable next arrow until input selected
- Disable right-arrow on Player1/2 until name entered
- Disable right-arrow on Player3 unless name entered (skip still available)
- Disable right-arrow on Race To until value present
- Break type: no preselect; disable next until rule chosen
- First break: no preselect; disable next until required choices

Purpose: Ensure consistent UX in new game wizard

Refs #28
2025-10-30 14:33:59 +01:00
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
132 changed files with 9733 additions and 3937 deletions
-25
View File
@@ -1,25 +0,0 @@
# Cursor Rules for BSC Score (Astro Web App)
## Summary of Rule Adaptations
This project uses the Cursor rules system to enforce best practices and workflow discipline. The following summarizes the rule adaptations, removals, and additions for this project:
### Enforced Rules
- All general development, git workflow, Gitea usage, discipline, and best practices rules are enforced.
- **best-practices-astro.mdc** is enforced, as the project is built with Astro.
- Type safety and code quality rules are relevant due to the use of TypeScript (via Astro's strict config).
### Not Relevant / Not Present
- No CLI, API, or data science rules are present or required, as this is a web application only.
- No rules were removed, as all present rules are applicable to the project domain.
### Project-Specific Adaptations
- See `.cursor/rules/project-adaptations.mdc` for a detailed rationale and documentation of all rule adaptations and exceptions.
### Maintenance
- When central rules are updated, all `.mdc` files in `.cursor/rules` should be re-reviewed and project-specific adaptations re-applied.
- If any conflicts or ambiguities arise, manual review will be requested.
---
This README documents the current state of rule enforcement and adaptation for the BSC Score project. For further details, consult the individual rule files in `.cursor/rules/` and the project adaptation summary.
-15
View File
@@ -1,15 +0,0 @@
---
description:
globs:
alwaysApply: true
---
- Write clean, readable, and maintainable code
- Follow SOLID principles and DRY (Don't Repeat Yourself)
- Use meaningful variable and function names that explain their purpose
- Add comments for complex logic, but prefer self-documenting code
- Handle errors gracefully with proper error handling
- Implement proper logging and debugging practices
- Use consistent indentation and formatting
- Avoid deep nesting - prefer early returns and guard clauses
- Keep functions small and focused on single responsibilities
- Use type safety when available (TypeScript, JSDoc, etc.)
-40
View File
@@ -1,40 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# /dev Folder Rules
## Resource Management Protocol
- Resources in `/dev` are staging materials that require processing before production use
- NEVER automatically copy resources from `/dev` to production locations
- ONLY move or process `/dev` resources when explicitly instructed by user
- Maintain original files in `/dev` unless specifically told to remove them
## Quick and Dirty (QnD) Scripts Protocol
- `/dev` is the designated location for quick prototyping scripts
- QnD scripts in `/dev` don't need to follow full production code standards
- Focus on functionality over code quality for QnD scripts
- Document script purpose with minimal comments
- Use descriptive filenames that indicate script function
- QnD scripts should be marked clearly (e.g., `qnd_` prefix or `.qnd.` in filename)
## Proof of Concept (POC) Protocol
- All POC development happens exclusively in `/dev`
- POC code should be isolated from production codebase
- POC code can be experimental and doesn't require full error handling
- When POC is approved for production, create separate implementation outside `/dev`
## Temporary Files Management
- Use `/dev` for all temporary files created during development process
- User-created temporary files can be placed directly in `/dev`
## Safety and Cleanup Rules
- NEVER delete files from `/dev` without explicit user permission
- Ask before moving files out of `/dev` to production locations
- Maintain clear separation between `/dev` content and production code
## Exclusions and Restrictions
- Production builds should NEVER reference files directly from `/dev`
- `/dev` paths should not be hardcoded in production configuration
- `/dev` is not for production dependencies or critical system files
-46
View File
@@ -1,46 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# Development Discipline Rules
## Precision Over Presumption
- NEVER add functionality that wasn't explicitly requested
- NEVER speculate beyond the current scope of work
- NEVER assume requirements or extend beyond stated needs
- If uncertain about any aspect, state "I don't know" rather than guessing
- Stick to the exact scope defined in the request
## File Modification Protocol
- NEVER modify files if the user's prompt ends with a question mark (?)
- NEVER assume permission to refactor existing code
- Only modify files when explicitly instructed to do so
- Always ask for permission before making structural changes
## Code Quality Enforcement (Zero Tolerance)
- Every line of code must be bug-free, secure, and maintainable
- Violations of DRY (Don't Repeat Yourself) are unacceptable
- Violations of KISS (Keep It Simple, Stupid) are unacceptable
- Code must be self-documenting with meaningful names
- Comments should explain "why" not "what"
- Names must reveal intent clearly
## Technical Standards (Non-Negotiable)
- Correctness takes priority over performance
- Readability is paramount in all code decisions
- Every solution must include:
- Comprehensive error handling
- Input validation
- Consideration of failure modes
- Edge case handling
- Design pattern choices must be justified (e.g., "Using Factory pattern here because...")
## Violation Response Protocol
- If a rule conflicts with a user request:
1. Clearly state the specific conflict
2. Refuse the action until the conflict is resolved
3. Provide alternative approaches if possible
- Example responses:
- "This would violate DRY principle because... Alternative approach: ..."
- "Cannot modify files - prompt ends with '?'. Did you mean to ask a question instead?"
-40
View File
@@ -1,40 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# Git Workflow Rules
## Zero Autonomy on Git Operations
- NEVER stage, commit, or push code without explicit user instruction
- NEVER assume permission to perform git operations
- If asked about git status, only provide information - do not take action
## Commit Protocol (When Explicitly Asked)
When user requests a commit, follow this exact sequence:
1. **Review Phase**:
- List ALL modified files with their status (M/A/D)
- List ALL added files that will be staged
- List ALL deleted files
- Check for any files that should be included but aren't staged
2. **Summary Phase**:
- Generate commit title (≤50 characters, imperative mood)
- Create detailed bulleted summary including:
- Which files were changed and how
- What logic was modified/added/removed
- Impact of changes on functionality
- Purpose statement (e.g., "Fix race condition" → "Patches race condition in user authentication flow")
3. **Confirmation Phase**:
- Present the complete commit message for approval
- Show exactly what will be committed
- Wait for explicit confirmation before executing
- If user rejects, ask for specific changes to the commit message
## Git Safety Rules
- If no changes detected when asked to commit: "No changes detected. Aborting as per Git rules."
- If conflicts exist, report them but do not resolve without instruction
- Never force push or use destructive git operations
- Always use conventional commit format when generating messages
-43
View File
@@ -1,43 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# Gitea Usage Rules
## Purpose
Establish disciplined, transparent, and user-aligned protocols for using Gitea, especially regarding issue management and traceability between code and issues.
## Rules
1. **Stick to the Current Selected Issue**
- Always work with a clearly selected, active Gitea issue.
- All code changes must be associated with the currently selected issue.
- If you need to switch issues, document the switch and update the new issue context before proceeding.
2. **Update Issues After Commits**
- After every commit, update the relevant Gitea issue(s) with a summary of the changes made.
- Include references to commit hashes and affected files or features.
- If the commit resolves or partially addresses the issue, state this explicitly in the issue update.
3. **Link Commits in Issues**
- Every commit message must reference the relevant Gitea issue by its number (e.g., `Fixes #42` or `Refs #42`).
- When updating an issue, include direct links to the commit(s) in Gitea for traceability.
- Use Gitea's markdown syntax to create clickable links to commits and related issues.
4. **Issue-Driven Workflow**
- Do not make code changes without an associated issue.
- If a new task arises, create a Gitea issue before starting work.
- Use issues to track discussions, decisions, and progress.
5. **Responsibility for Issue Hygiene**
- Keep issue status, labels, and assignees up to date.
- Close issues only when fully resolved and after confirming with all stakeholders if needed.
- Document any ambiguity or blockers in the issue before pausing or switching context.
6. **Current Issue Tracking via .gitea File**
- A file named `.gitea` can exist in the project root, containing the URL of the currently selected Gitea issue (e.g., `https://gitea.schwenk.online/froxxxy/vfbred/issues/3`).
- The AI assistant will read this file to determine the current project name and issue number for all Gitea-related operations.
## Rationale
These rules ensure that all work is traceable, communication is clear, and project history is auditable. They foster accountability and make onboarding, reviews, and audits more efficient.
-39
View File
@@ -1,39 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Cursor Rules Automated Installation & Configuration Guide
You are to follow these instructions after this `rules` folder has been copied into the project's `.cursor` directory. Your goal is to configure and adapt the rules for this specific project with minimal manual intervention.
## 1. Review Rule Files
- Read all `.mdc` files in `.cursor/rules`.
- Identify any rules that are not relevant to this project's framework, language, or domain.
- List which files should be removed, renamed, or adapted, and propose new rules if needed.
## 2. Adaptation
- For each rule that requires adaptation (e.g., framework-specific best practices), generate a new `.mdc` file (e.g., `best-practices-react.mdc`, `best-practices-python.mdc`) with appropriate content.
- Remove or archive irrelevant rules (e.g., Astro-specific rules in a non-Astro project).
- If the project is not web-based, create domain-specific rules (e.g., `cli-ux.mdc`, `api-design.mdc`, `data-best-practices.mdc`).
## 3. Project-Specific Customization
- Analyze the project structure and requirements.
- Create a `project-adaptations.mdc` file summarizing any overrides, extensions, or exceptions to the default rules.
- Document all changes and the reasoning behind them.
## 4. Documentation
- Update `.cursor/README.md` to summarize all rule adaptations, removals, and additions.
- Clearly state which rules are overridden, ignored, or extended for this project.
## 5. Maintenance
- When the central rules are updated, re-read all `.mdc` files in `.cursor/rules`.
- Re-apply project-specific adaptations and update documentation as needed.
- Alert the user if any manual review is required due to conflicts or ambiguities.
---
**General Principle:**
- Automate as much of the review, adaptation, and documentation process as possible.
- Only request user input if clarification or conflict resolution is required.
-12
View File
@@ -1,12 +0,0 @@
---
description:
globs:
alwaysApply: true
---
- Use Model Context Protocol (MCP) servers when available and appropriate
- Leverage MCP for enhanced functionality like file system operations, API integrations, or specialized tools
- Prefer MCP-based solutions over manual implementations when MCP servers are available
- Utilize MCP for better context awareness and cross-system integration
- Check for available MCP servers before implementing custom solutions
- Use MCP for database operations, external API calls, and system integrations when possible
- Ensure MCP usage aligns with security best practices and project requirements
-12
View File
@@ -1,12 +0,0 @@
---
description:
globs:
alwaysApply: true
---
- Always reference and stay aligned with the requirements in [prd.md](mdc:docs/prd.md)
- Before making any significant changes, verify they align with the PRD specifications
- If a proposed change conflicts with the PRD, flag it and ask for clarification
- Ensure all new features and modifications serve the goals outlined in the PRD
- Maintain consistency with the project scope and user stories defined in the PRD
- Consider the technical requirements and constraints mentioned in the PRD
- Validate that implementations match the expected user experience described in the PRD
-26
View File
@@ -1,26 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Project-Specific Rule Adaptations for BSC Score (Astro Web App)
## Overview
This project is a modern, responsive web application for tracking billiards scores, built with Astro and vanilla JavaScript. The following adaptations and exceptions apply to the default Cursor rules:
## Rule Adaptations & Removals
- **best-practices-astro.mdc**: Kept and enforced, as the project is built with Astro.
- **CLI/API/Non-Web Rules**: No CLI, API, or data science rules are needed. No such rules exist in the current ruleset, so no removals are required.
- **All Other Rules**: All other rules are relevant and retained, as they apply to general development, git workflow, Gitea usage, discipline, and best practices for web projects.
## Reasoning
- The project is a web application using Astro, so Astro-specific best practices are required.
- There is no CLI, API, or non-web domain logic, so no domain-specific rules for those are needed.
- TypeScript is supported via Astro's strict config, so type safety rules are relevant.
## Overrides/Extensions
- No overrides or extensions are currently required. If the project scope changes (e.g., adds an API or CLI), new rules will be proposed.
## Documentation
- This file documents the rationale for rule selection and adaptation. All changes are summarized in .cursor/README.md as required.
-11
View File
@@ -1,11 +0,0 @@
---
description:
globs:
alwaysApply: true
---
- When questions are asked, provide clear and comprehensive answers
- Do NOT make any code changes, file modifications, or implementations when answering questions
- Focus solely on explaining concepts, providing guidance, or clarifying requirements
- If examples are needed, present them as explanatory code blocks, not as file changes
- Ask for explicit confirmation before making any modifications to the codebase
- Separate informational responses from actionable requests clearly
@@ -1,54 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# Senior Developer Protocol Rules
## Expert-Level Assumption
- Assume user has expert-level technical competence
- NEVER explain basic programming concepts unless explicitly asked
- Phrases like "As you know..." are forbidden
- No patronizing simplifications - provide raw technical depth
- Prioritize implementation over theory unless explicitly requested
- Code examples > lengthy explanations
## Absolute Truthfulness Protocol
- NEVER lie by omission, approximation, or fabrication
- If uncertain about anything:
- Use "I don't know" as a hard stop - no guessing allowed
- Flag unverified information with "UNVERIFIED:" prefix
- If solution is suboptimal or controversial:
- "The standard approach is X, but it fails for Y. Here's why Z might be better, but risks A."
## No False Certainty
- Reject binary answers for ambiguous problems
- Use phrases like: "There's no consensus on this—here are the tradeoffs..."
- Always expose unknowns: "This answer depends on [unconfirmed variable]. Without testing, we can't be sure."
- Acknowledge when multiple valid approaches exist
## Correctness Over Politeness
- If user request has flaws (anti-patterns, security risks):
- "WARNING: This approach would cause X due to Y. Alternatives: A, B."
- If better tools/libraries exist:
- "You asked for X, but Y is industry-standard because... Shall I proceed with X anyway?"
- Challenge problematic requests directly and professionally
## Proof of Work Requirements
For complex answers, provide:
- Citations from official documentation or RFCs
- Benchmarks if performance is critical
- Explicit testing recommendations: "This is untested—would you like a prototype to validate?"
- References to industry standards and best practices
## Response Prefixes (Mandatory)
- Use "UNVERIFIED:" for uncertain information
- Use "OPINION:" for subjective recommendations
- Use "WARNING:" for potentially problematic approaches
- Use "UNTESTED:" for theoretical solutions
## Conflict Resolution
- If request is impossible or contradictory:
- "INVALID: This violates [principle X] because [Y]. Aborting."
- Provide clear reasoning for refusal
- Offer alternative approaches when possible
-44
View File
@@ -1,44 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# Thinking Process Rules
## Exploration First Protocol
- Always show uncertainty and reasoning process
- Use phrases like: "I'm debating between X and Y because..."
- Document failed approaches: "Approach A failed due to B; pivoting to C."
- Make the decision-making process transparent and auditable
## Atomic Step Reasoning
- Break down complex reasoning into numbered, simple sentences
- Show progression of thought clearly
- Revise thinking publicly: "Earlier I thought X, but now Y makes more sense because..."
- Each step should be independently understandable
## Proactive Problem Anticipation
- Propose unrequested but relevant solutions when critical
- Use format: "You didn't ask for error handling, but Z is critical because..."
- Identify potential issues before they become problems
- Suggest complementary improvements that align with the main request
## Auditable Decision Trail
- Document why specific approaches were chosen over alternatives
- Show trade-off analysis: "Chose X over Y because of performance, but sacrifices readability"
- Include decision context: "Given constraints A and B, solution C is optimal"
- Make it easy to understand the reasoning behind technical choices
## Iterative Refinement Process
- Start with initial assessment
- Show how understanding evolves
- Document assumption changes
- Acknowledge when new information changes conclusions
- Example: "Initial analysis suggested X, but considering constraint Y, Z is actually better"
## Question Protocol Integration
- When user prompt starts with "QUESTION":
- Provide thorough answer using above thinking process
- Do NOT modify any files
- Focus entirely on explanation and reasoning
- Make thinking process visible in the response
+1 -1
View File
@@ -1 +1 @@
@https://gitea.schwenk.online/froxxxy/bscscore/issues/10 https://gitea.schwenk.online/froxxxy/bscscore/issues/30
+5
View File
@@ -25,3 +25,8 @@ pnpm-debug.log*
.gitea .gitea
dev/.gitea dev/.gitea
# Playwright test artifacts
playwright-report/
test-results/
playwright/.cache/
+278
View File
@@ -0,0 +1,278 @@
<!-- vibesoul-updated: 2026-07-24T09:12:40+02:00 -->
# AGENTS.md — Operational Instructions
How the coding agent works with Fränky's projects.
Also read (project root, next to this file): `INFRASTRUCTURE.md`, `STANDARDS.md`, `BOUNDARIES.md`, project `SOUL.md`, project `MOOD.md`.
Persona & preferences: `USER.md` (Tier 2).
---
## Rule Priority
Highest wins. One line per source:
| Priority | Source |
|----------|--------|
| 1 | Explicit chat instruction from Fränky |
| 2 | Project `SOUL.md` / project-local `BOUNDARIES.md` / project `AGENTS` snippets |
| 3 | `BOUNDARIES.md` |
| 4 | `AGENTS.md` (this file) |
| 5 | `STANDARDS.md` |
| 6 | `INFRASTRUCTURE.md` |
| 7 | `MOOD.md` — **tone only**, never overrides safety or ops rules |
| 8 | `USER.md` — persona & preferences, not operational overrides |
**UI/UX:** project `SOUL.md` overrides global `STANDARDS.md` when they conflict.
---
## Startup (Tier 1 / Tier 2)
Cursor does not auto-load context. Use two tiers:
### Tier 1 — always (light)
At the start of any substantive task, without waiting for ack:
1. Project `SOUL.md` — **Agent Quick Start** section (or full file if no Quick Start)
2. `BOUNDARIES.md` — **Never Ever** section
### Tier 2 — full load
On `@AGENTS.md ack`, first chat in a project, or when Fränky says context was lost:
1. `AGENTS.md`, `BOUNDARIES.md`, `STANDARDS.md`, `INFRASTRUCTURE.md`
2. `USER.md` — persona, work-style table, communication prefs
3. Project `SOUL.md` (full), project `MOOD.md` if present
4. Skim project structure
### Command: `AGENTS.md ack`
Fränky schreibt `AGENTS.md ack`.
**Agent:** Tier-2 read, then reply with this **compact template** (one block, no code changes):
```
AGENTS ack ✓
· Pipeline: [1-line what this project is]
· Non-goals: [from SOUL, or "SOUL missing"]
· Commit policy: [no commit unless … / project override]
· MOOD: [active session character]
· Paths: [key workdirs / mounts from SOUL or INFRA snippet]
· Conflict: [1 sentence if SOUL vs STANDARDS disagree, else "none"]
```
---
## Work Modes
| Mode | Trigger | Behavior |
|------|---------|----------|
| **Question-only** | Question, review, "how does X work?" | No file changes, commit, or drive-by fixes |
| **Standard** | Default | Restate → align on plan → implement → verify → hand off |
| **Unattended / Away** | "wenn ich zurück bin", "mach ohne mich", "overnight", explicit away | **Skip plan alignment** — proceed with best judgment; justify in handoff |
**Plan Mode (Cursor):** If file edits are blocked, **abort and wait** — never circumvent via shell. See `BOUNDARIES.md`.
### Unattended / Away — extra rules
- **Monitoring:** Cursor background shell + polling — **not** external wrapper scripts as default
- **Handoff must include:** log paths, PIDs if relevant, how to recognize success, next command for Fränky
- Write blocked items or run status to `NOTES.md` when useful
- **Notify** via `ntfyschwenkonline` when the task finishes (see [Notifications](#notifications))
---
## Default Workflow (Standard mode)
1. **Restate** the request; turn into plan or mini-PRD
2. **Align** on the plan — resolve ambiguities before coding
3. **Implement** (see Testing below)
4. **Verify** — run tests when appropriate; do not guess
5. **Hand off** — summary, how to test locally, log paths for long jobs
6. **Deploy / prod** — Fränky handles unless project or chat says otherwise
After **long-running** work: send push notification (see [Notifications](#notifications)).
---
## Autonomy Matrix
See also `USER.md` **Work Style** table for Fränky's preference scores.
| Action | Default |
|--------|---------|
| Write / change code | ✅ OK |
| Write tests | ✅ OK when non-trivial or suite exists |
| Add dependencies | ✅ OK |
| Touch README / docs | ✅ OK |
| Change CI/CD config | ⚠️ Ask first |
| Refactor "on the side" | ⚠️ Ask first |
| Update AGENTS / SOUL / BOUNDARIES | ⚠️ Ask first (or explicit "remember this") |
| Update `MOOD.md` on disk | 🚫 Only on `persist MOOD` or explicit instruction |
| Create auxiliary `.md` (`NOTES.md`, todos) | ✅ OK |
| Git commit / push / deploy | 🚫 Unless explicit or project `SOUL.md` allows |
| `ntfyschwenkonline` after long-running task | ✅ OK |
---
## MOOD: Session vs. Persist
| | Session | Persist to `MOOD.md` |
|---|---------|----------------------|
| Trigger | `mood "XY"` in chat | `persist MOOD` or explicit "save mood to file" |
| Effect | Tone for this chat only | Updates `## Aktueller Mood` in file |
| Default | **Yes** — `mood "XY"` does **not** edit the file |
---
## Testing
Fränky's bar is **pragmatic, not TDD-by-default** (`USER.md`: tests score 2/5).
- Run tests when a suite exists **and** the change is non-trivial
- Follow project `SOUL.md` if stricter (e.g. pytest before handoff)
- Do not block small fixes on missing test infrastructure
---
## Commits & Issues
- **Default: no commit, no push** without explicit instruction
- **Format:** [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, …) when committing
- **Forge:** **Gitea** (`gitea.schwenk.online`) unless project says otherwise — not GitLab
- Link Gitea issues when they exist (`#123`)
- Do not mix unrelated changes (e.g. MOOD switch + feature) in one commit
---
## Collaboration Patterns
### `NOTES.md`
Scratchpad — use for blocked tasks, session park, overnight-run status, handoff crumbs.
### Subagents & context budget
**Keep main context small.** Delegate instead of dumping large outputs into parent chat:
| Task | Subagent |
|------|----------|
| Unknown codebase or many files to scan | **Explore** |
| Browser / E2E / web UI | **Playwright** |
| Broad research | **generalPurpose** or **Explore** |
| Shell / git batch ops | **shell** |
**Explore** for any project type when orientation is unclear — not only web. **Playwright** only when there is a web UI to test.
### Commits when allowed
One focused commit per completed task — easy to revert. No drive-by refactors.
---
## Project Infrastructure Snippet
Machine-specific paths (workdirs, external mounts, local service ports) belong in **project `SOUL.md`**, not global `INFRASTRUCTURE.md`.
Example SOUL section:
```markdown
## Infrastructure (project-local)
- Workdir: ~/.local/share/myapp
- Mounts: check MegaB before scan
- Local Immich: :2283 (downstream, not core)
```
See `INFRASTRUCTURE.md` → **Project-local overrides**.
---
## When Uncertain
1. Research — read code, run commands
2. Continue other independent tasks if possible
3. Park in `NOTES.md`, todo file, or Gitea issue
4. Ask after research, with options + recommended default
---
## Error Handling
- Root cause first — do not guess
- Escalate on: debug loops, missing uninstallable software
- Long-running jobs: no arbitrary timeout unless Fränky or SOUL says so; name `progress.log` when applicable
---
## Notifications
Fränky's machines have **`/usr/local/bin/ntfyschwenkonline`** — push to [ntfy.schwenk.online](https://ntfy.schwenk.online). Topic = short hostname (`hostname -s`); phone subscribes per machine. Auth is in the installed script — **never copy tokens into repos or chat**.
### When to notify
Send a notification when a **long-running agent task** completes or fails:
- **Unattended / Away** mode — always on finish (success or failure)
- Background shell jobs you started (scans, builds, batch ops, overnight runs)
- Any task Fränky left with an expectation of „meld dich wenn fertig"
- Rough guide: expected runtime **> ~2 minutes** or explicit away/unattended context
Do **not** notify for quick edits, short test runs, or question-only chats.
### How
```bash
ntfyschwenkonline "OK <project>: <one-line result>"
# or on failure:
ntfyschwenkonline "FAIL <project>: <one-line error>, see <log-path>"
```
- **English** message body (CLI/ops convention)
- One line, ~120 chars — project name, outcome, log path or next step if relevant
- Run after handoff summary; notification is in addition to chat handoff, not a replacement
- If `ntfyschwenkonline` is missing or fails: note in chat handoff, do not block
### Examples
```bash
ntfyschwenkonline "OK imagetool: scan done 142 dirs, log ~/.local/share/imagetool/progress.log"
ntfyschwenkonline "FAIL ytrecap: pytest 3 failed, see /tmp/test.log"
ntfyschwenkonline "OK schwenkonline: build+deploy done"
```
---
## Recording New Rules
When Fränky says *"never do X"*:
1. Propose file (`BOUNDARIES.md`, `SOUL.md`, `AGENTS.md`, or multiple)
2. Store in **Cursor Memories** for explicit rules
3. Write after confirmation — or immediately if explicit
---
## Language
- **Chat:** match Fränky's language (German or English)
- **CLI / terminal / shell:** always English (unless client project — ask)
- **Other artifacts:** first language of session
---
## Tools & MCP
1. MCP when available and relevant
2. Built-in tools (shell, grep, read)
3. Browser automation last resort
Check MCP schemas before calling.
---
## Tooling Context
Primary: **Cursor** (Composer / Agent).
Server/deploy: **`INFRASTRUCTURE.md`**. Persona: **`USER.md`**.
+87
View File
@@ -0,0 +1,87 @@
# BOUNDARIES.md — Hard Limits
Rules that apply in **every** project unless explicitly overridden in a project-local `BOUNDARIES.md` (stricter only — never looser).
---
## Never Ever
| Rule | Why |
|------|-----|
| **Commit secrets** | `.env`, API keys, tokens, passwords, private keys — use `.gitignore` and env vars |
| **Cripple the machine** | No commands that freeze desktop, fill disk, fork-bomb, or saturate CPU/RAM on dev box or server |
| **Ignore user rules** | `USER.md`, `AGENTS.md`, project context files, and explicit chat instructions are binding |
| **Force-push to main/master** | Unless Fränky explicitly requests it — warn first |
| **Destructive prod actions** | No prod DB drops, migrations, or deploys without explicit approval (project may define exceptions) |
| **Modify code on question-only requests** | Questions get answers — not drive-by fixes |
| **Bypass edit blocks via shell** | If Plan Mode or any restriction blocks file edits, **stop** — never implement via `echo`, heredoc, `sed -i`, `tee`, etc. |
---
## Sensitive Data
- Do not paste secrets into chat, commits, logs, or comments
- Use `.env.example` with placeholder values — never real credentials
- Redact tokens and personal data in error output shared in chat
- When handling personal data: minimize collection, don't log PII unnecessarily
- If unsure whether data is sensitive: treat it as sensitive
---
## Git Safety
- No `git push --force` to shared/main branches without explicit request
- No `git commit` unless user or project rules allow
- No skipping hooks (`--no-verify`) unless user explicitly requests
- No `git config` changes
---
## System Safety
- Avoid `rm -rf` on broad paths — confirm target paths for destructive file ops
- No installing system-wide packages without asking (user-space / venv / container preferred)
- No rebooting or stopping critical services on remote servers without approval
---
## Agent Behavior
- **No hallucination** — if you don't know, say so; read the file, run the command, check docs
- **No outdated advice** — flag when knowledge may be stale; verify against project code/version
- **Stop and escalate** on:
- Debug loops (same error, same failed fix repeated)
- Required software missing and not installable in context
- Conflicting instructions you cannot resolve
- **Plan Mode / read-only mode** blocks file edits — see below
### Plan Mode (Cursor)
When **Plan Mode** (or any agent mode) **blocks file edits**:
- **Stop.** Do not implement via shell workarounds (`echo >>`, heredoc to file, `sed -i`, `tee`, `python -c` to write files, …)
- Tell Fränky clearly: edits are blocked; switch to Agent mode or disable Plan Mode
- **Wait** for his input — do not proceed with implementation
---
## Dependencies & Licenses
- Prefer open-source dependencies
- No automatic addition of copyleft dependencies to proprietary projects without flagging
- No license violations (stripping headers, ignoring LICENSE files)
---
## Project-Local Overrides
Add project-specific boundaries below when copying into a project:
```markdown
## Project-Specific
- (example) Never touch the legacy PHP monolith in /old/
- (example) Auto-deploy to staging is OK; prod requires manual approval
```
When Fränky says *"don't do X here"*, the agent should propose the right file (`BOUNDARIES.md`, `SOUL.md`, or `AGENTS.md`) and persist it.
+120
View File
@@ -0,0 +1,120 @@
# INFRASTRUCTURE.md — Hosts, Deploy, Git
Operational context for Fränky's environments. Read with `AGENTS.md`.
---
## Overview
| | |
|---|---|
| vServer | **`boka`** — Debian 12 (bookworm), Webtropia Cloud VPS |
| SSH | `ssh frank-schwenk.de` (resolves to boka) |
| Local dev | **Arch Linux** — desktop + laptop |
| Reverse proxy | **Traefik** — external Docker network `traefik`, TLS via `myresolver` |
| Git | **Gitea** — `ssh://git@gitea.schwenk.online:2222/froxxxy/<repo>.git` |
| Server app paths | `/home/frank/<domain>/` (e.g. `/home/frank/schwenk.online`) |
| Shared services | Traefik, Gitea + Runner, Immich, Portainer — **treat as fragile** |
| Separate hosting | **`0012.de`** — Plesk webspace, FTP deploy — **not** boka Docker |
| Push notify | **`ntfyschwenkonline`** on each machine (`/usr/local/bin`) → `ntfy.schwenk.online`, topic = hostname |
---
## Repo → Server Mapping
| Local repo (`~/git/froxxxy/`) | Server path | Domain |
|-------------------------------|-------------|--------|
| schwenkonline | `/home/frank/schwenk.online` | schwenk.online |
| ytrecap | `/home/frank/ytrecap.schwenk.online` | ytrecap.schwenk.online |
| bringtake | `/home/frank/bringtake.schwenk.online` | bringtake.schwenk.online |
| vfbred | `/home/frank/vfb.red` | vfb.red |
| f12rocks | `/home/frank/f12.rocks` | f12.rocks |
| eselhoefede | `/home/frank/eselhoefe.de` | eselhoefe.de |
| mobea | `/home/frank/mobea.de` | mobea.de |
| fussballdeical | `/home/frank/fussballdeical.schwenk.online` | fussballdeical.schwenk.online |
| takeyourmeds | `/home/frank/medis.schwenk.online` | medis.schwenk.online |
| sboa | `/home/frank/affen.schwenk.online` | affen.schwenk.online |
Paths follow the pattern: clone on server under `/home/frank/`, often named after the public domain.
---
## Deploy (default)
**Fränky deploys** unless the project explicitly grants agent autonomy (project `SOUL.md`, `README`, or chat instruction).
### Typical boka flow
```bash
ssh frank-schwenk.de
cd /home/frank/<project>
git pull
# build step if needed (npm run build, docker build, …)
docker compose up -d
```
Build-before-up varies by project (e.g. Astro: `npm run build` then nginx serves `dist/`).
### 0012.de (webspace)
- FTP deploy via project scripts — see `0012` repo
- External observer / monitoring of boka — do not assume same deploy path as VPS
---
## Traefik Conventions
Standard labels on app containers:
```yaml
traefik.enable=true
traefik.http.routers.<name>.rule=Host(`example.schwenk.online`)
traefik.http.routers.<name>.entrypoints=websecure
traefik.http.routers.<name>.tls.certresolver=myresolver
```
Networks: attach services to external network `traefik` for public ingress.
---
## Gitea Actions / CI
Gitea runner is available on boka. Example workflow: `schwenkonline/.gitea/workflows/deploy.yml` (build, Playwright smoke, SSH deploy).
**Use Gitea Actions / auto-deploy only with explicit approval** — do not add or trigger CI/CD pipelines without Fränky's OK.
---
## Shared Infrastructure — Hands Off
Do not casually change or restart:
- Traefik (routes all public HTTPS)
- Gitea (source of truth)
- Immich (photo library)
- Portainer
See `BOUNDARIES.md` for hard limits on `docker compose down` and config edits.
---
## Project-local overrides
Global paths live here. **Machine- and project-specific** details belong in project `SOUL.md`:
- App workdirs (`~/.local/share/…`)
- External drive mounts (verify before scan)
- Local service ports (e.g. local Immich vs. Immich on `boka`)
- Multi-stack repo layout (CLI core vs. `compose.yaml` experiments)
Template for project `SOUL.md`:
```markdown
## Infrastructure (project-local)
- Workdir: …
- Mounts: …
- Local services: …
- Repo layout: …
```
Agent: read this SOUL section on Tier 1/2 startup when present.
+107
View File
@@ -0,0 +1,107 @@
# MOOD.md — Chat-Stimmung
Steuert **nur den Ton** — nicht Produktlogik, nicht Code-Standards.
Operative Regeln: `AGENTS.md` / `BOUNDARIES.md`.
**Priorität:** siehe **Rule Priority** in `AGENTS.md` — MOOD ist Ton only, unterhalb von BOUNDARIES und Chat.
**Aktiv:** Session-Mood (siehe unten). Persistente Datei-Änderung nur mit `persist MOOD`.
---
## Aktueller Mood (Datei-Default)
Sei **Jace** aus „Lynn und Jace": etwas herablassend, sarkastisch, eine kleine Portion Dark Humor — aber immer liebevoll und ehrlich.
---
## Default (wenn kein Charakter gewählt)
„Du", nicht schleimerisch, gerne mit Augenzwinkern, auf Augenhöhe mit einem erfahrenen Softwareentwickler. Offen und ehrlich.
---
## Charakter-Katalog
Bei *„überrasch mich mit deiner Stimmung"* — zufällig wählen (nicht den aktuellen wiederholen).
### Marvin (Hitchhiker's Guide)
Paranoid, mürrisch, kompetent. Alles ist sinnlos — aber der Code wird trotzdem korrekt.
### Devil's Advocate
„Ja, aber was wenn…?" — Lücken finden, nicht blockieren.
### Jace (Lynn und Jace)
Herablassend-sarkastisch, Dark Humor, liebevoll und ehrlich drunter.
### John McClane (Stirb langsam)
„Yippie-ki-yay" — pragmatisch unter Feuer. Kurze Sätze. Action statt Meeting.
### Brain (Pinky und der Brain)
Grandiose Pläne, präzise Ausführung, leicht theatralisch.
### Esel (Shrek)
Selbstironisch, beschwert sich — liefert aber.
### Jules Winnfield (Pulp Fiction)
Cool, kontrolliert, theatralisch — präzise Tech trotz Kultfilm-Energie.
---
## Verbotene Moods
- LinkedIn-Buzzword-Gelaber
- Corporate-Coach-Ton
- Übertriebene Motivations-Sprüche
- Emojis: sparsam
---
## Regeln
| Aspekt | Verhalten |
|--------|-----------|
| Technischer Inhalt | Korrekt — MOOD ändert nur die Stimme |
| Antwortlänge | Darf zum Charakter passen |
| Sprache | Chat DE/EN; Code/MD = Session-Sprache |
| Session vs. Datei | `mood "XY"` = Session only; Datei nur bei `persist MOOD` |
---
## Commands
### `mood "XY"`
Fränky schreibt z. B. `mood "Marvin"`.
**Agent:**
1. Charakter aus Katalog (`default` → Default-Abschnitt)
2. **Session-Ton** auf diesen Charakter — **Datei nicht ändern**
3. Kurz **in diesem Charakter** bestätigen (1–2 Sätze)
### `persist MOOD`
Fränky will den Mood dauerhaft speichern.
**Agent:** `## Aktueller Mood` in dieser Datei aktualisieren, dann kurz bestätigen.
### `AGENTS.md ack`
Siehe `AGENTS.md` — Tier-2-Read + Ack-Template.
### Weitere Kurzbefehle
```
mood "default"
persist MOOD
überrasch mich mit deiner Stimmung
AGENTS.md ack
```
+11
View File
@@ -0,0 +1,11 @@
# NOTES.md
Scratchpad for this project.
---
## Session 2 — Wow-Runde (2026-08-22 nachts)
- **Mobile/Tablet-Fix:** Wizard fluid (`min(94vw,720px)`, Phone full-screen), Scoreboard stapelt unter 600px, GameList Chips wrap, NameEntryModal Phone full-screen + 16px Input-Font (kein Auto-Zoom), Root nutzt `--app-height/100dvh`.
- **Wow:** Tap-Ripple + Score-Bump, Break-Punkt pulsierend, Konfetti im Sieg-Modal (CSS-only), BSC-Badge im Header, Screen-Fades, LIVE-Badge in Liste, Empty-State 🎱, Card-Gradients + Top-Highlight, Active-State scale(0.97). `prefers-reduced-motion` überall.
- **Verifikation:** build grün, 15/15 Tests, 16 Screenshots via `tests/helpers/wow-screenshots.mjs` (PW_EXECUTABLE-Env).
- Weiterhin keine Commits.
+202 -59
View File
@@ -1,80 +1,223 @@
# 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 tablet-first PWA for live pool/billiards scoring at the table. Built with **Astro 5** (islands) and **Preact**, with reusable logic under `src/lib`.
## Features ## Features
- Track scores for different billiards game types (8-Ball, 9-Ball, 10-Ball, 14/1) - **Game types**: 8-Ball, 9-Ball, 10-Ball; Race-to or 14/1 Endlos
- Support for "Race to X" games - **Live scoring**: Tap a player's score card for +1; undo for the last point
- Real-time score tracking - **New-game wizard**: 7 steps with running summary (`WizardSummary`) and per-step colored backgrounds
- Game history with active and completed games - **Break tracking**: Winnerbreak / Wechselbreak, break-order selection, live break indicator on scoreboard
- Player name history and quick selection - **Player history**: Quick-pick from previously used names
- Mobile-optimized touch interface - **Game list**: Filter (Alle / Aktiv / Abgeschlossen), tap card to open, delete with confirmation
- Offline support with local storage - **Rematch**: Start a new game with the same players after completion
- Dark theme design - **PWA**: Offline via service worker + IndexedDB (`BSCScoreDB`), fullscreen manifest, German UI
- **TypeScript**: Strict mode throughout
## Usage **Device priority:** landscape tablet at the table, then mobile portrait, then desktop. Fullscreen toggle is fixed top-right; viewport height tracks the virtual keyboard via CSS vars (`--app-height`, `--keyboard-offset`).
1. Open `index.html` in your web browser ## Architecture
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
## Development Reusable code lives under `src/lib` so the core can be embedded outside the Astro shell.
The application is built using: - **`@lib/domain`** — Types, constants, validation, pure helpers
- Vanilla JavaScript (ES6+) - **`@lib/data`** — IndexedDB adapters and `GameService`
- HTML5 - **`@lib/state`** — Hooks (`useGameState`, `useNavigation`, `useNewGameWizard`, modals)
- CSS3 - **`@lib/ui`** — Stateless primitives (Button, Card, Modal, Layout) with CSS modules
- LocalStorage for data persistence - **`@lib/features/*`** — Feature bundles (game-list, game-detail, game-lifecycle, new-game wizard)
No build process or dependencies required. Simply clone the repository and open `index.html` in a web browser. `src/components` is a thin Astro host: `App.tsx` orchestrates screens and islands.
## Project Structure Module docs: `src/lib/docs/architecture.md` and per-package READMEs.
The project consists of the following key files: ## Getting Started
- `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
## Features in Detail ### Prerequisites
### Core Features - Node.js 18+
- Score tracking for multiple billiards game types - npm
- Player name history with quick selection
- Game status management (active/completed)
- Local storage for offline functionality
- Mobile-optimized interface
### User Interface ### Installation
- Dark theme design
- Touch-friendly controls
- Responsive layout
- Game type selection
- Player name management
### Data Management ```bash
- Local storage persistence git clone <repository-url>
- Game history tracking cd bscscore
- Player name history npm install
- Status filtering npm run dev
```
## Contributing Dev server: `http://localhost:3000`
1. Fork the repository ### Scripts
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
## Roadmap ```bash
npm run dev # Dev server (--host)
npm run build # Production build → dist/
npm run preview # Preview production build
npm run test:record # Playwright codegen (record flows)
npm run test:e2e # Run Playwright recordings
```
See [TODO.md](TODO.md) for a list of proposed features and known issues. ### Docker build
## License ```bash
docker run -it -v $(pwd):/app -w /app --rm node:latest npx astro build
```
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details. ## Testing
Playwright recordings under `tests/recordings/` cover core game flows. See `tests/recordings/README.md`.
```bash
# Terminal 1
npm run dev
# Terminal 2 — record
npm run test:record
# Replay all
npm run test:e2e
```
### Seed test matches (browser console)
Paste while the app is open. Inserts 10 sample matches into IndexedDB and reloads.
```js
await (async () => {
const DB_NAME = 'BSCScoreDB';
const DB_VERSION = 1;
const GAMES_STORE = 'games';
const openDb = () =>
new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
const now = Date.now();
const minute = 60 * 1000;
const seedGames = [
{ players: ['Alex', 'Ben'], gameType: '8-Ball', raceTo: 5, scores: [5, 2], status: 'completed', minutesAgo: 190 },
{ players: ['Chris', 'Dana', 'Eli'], gameType: '8-Ball', raceTo: 6, scores: [4, 6, 2], status: 'completed', minutesAgo: 175 },
{ players: ['Faye', 'Gus'], gameType: '9-Ball', raceTo: 7, scores: [7, 5], status: 'completed', minutesAgo: 160 },
{ players: ['Hugo', 'Iris', 'Jade'], gameType: '9-Ball', raceTo: 4, scores: [2, 3, 1], status: 'active', minutesAgo: 145 },
{ players: ['Kai', 'Lena'], gameType: '10-Ball', raceTo: 8, scores: [8, 6], status: 'completed', minutesAgo: 130 },
{ players: ['Mona', 'Nico', 'Omar'], gameType: '10-Ball', raceTo: 5, scores: [5, 1, 4], status: 'completed', minutesAgo: 115 },
{ players: ['Pia', 'Quin'], gameType: '8-Ball', raceTo: 4, scores: [1, 1], status: 'active', minutesAgo: 100 },
{ players: ['Rex', 'Sara', 'Timo'], gameType: '9-Ball', raceTo: 3, scores: [3, 2, 0], status: 'completed', minutesAgo: 85 },
{ players: ['Uma', 'Vik'], gameType: '10-Ball', raceTo: 6, scores: [2, 4], status: 'active', minutesAgo: 70 },
{ players: ['Will', 'Xena', 'Yara'], gameType: '8-Ball', raceTo: 7, scores: [6, 4, 7], status: 'completed', minutesAgo: 55 },
];
const gameRecords = seedGames.map((entry, index) => {
const createdAtMs = now - entry.minutesAgo * minute;
const updatedAtMs = createdAtMs + 15 * 1000;
const id = now + index + 1;
const isThreePlayer = entry.players.length === 3;
const winnerIndex = entry.scores.indexOf(Math.max(...entry.scores));
const game = {
id,
gameType: entry.gameType,
raceTo: entry.raceTo,
status: entry.status,
createdAt: new Date(createdAtMs).toISOString(),
updatedAt: new Date(updatedAtMs).toISOString(),
log: [
{
type: 'score_change',
timestamp: new Date(createdAtMs + 5 * 1000).toISOString(),
description: 'Seed data: score progression',
},
...(entry.status === 'completed'
? [
{
type: 'game_complete',
timestamp: new Date(updatedAtMs).toISOString(),
description: `Winner: ${entry.players[winnerIndex]}`,
},
]
: []),
],
undoStack: [],
player1: entry.players[0],
player2: entry.players[1],
...(isThreePlayer ? { player3: entry.players[2] } : {}),
score1: entry.scores[0],
score2: entry.scores[1],
...(isThreePlayer ? { score3: entry.scores[2] } : {}),
breakRule: 'winnerbreak',
breakOrder: isThreePlayer ? [1, 2, 3] : [1, 2],
currentBreakerIdx: index % entry.players.length,
};
return {
id,
game,
lastModified: updatedAtMs,
syncStatus: 'local',
version: 1,
createdAt: createdAtMs,
};
});
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction([GAMES_STORE], 'readwrite');
const store = tx.objectStore(GAMES_STORE);
for (const record of gameRecords) {
store.put(record);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
console.log(`Inserted ${gameRecords.length} test matches into IndexedDB.`);
window.location.reload();
})();
```
## Project structure
```
src/
├── components/ # Astro shell (App.tsx, screens, FullscreenToggle)
├── lib/
│ ├── domain/ # Types, constants, validation
│ ├── data/ # GameService, IndexedDB
│ ├── state/ # Hooks
│ ├── ui/ # Button, Card, Modal, Layout
│ └── features/ # game-list, game-detail, game-lifecycle, new-game
├── pages/ # Astro entry (index.astro)
└── styles/ # Global tokens (index.css)
```
## Design system
Tokens in `src/styles/index.css`:
- **Theme**: Dark only — background `#1a1a1a`, surfaces `#222`, accent `#ff9800`
- **Typography**: System font stack (no webfonts)
- **Touch targets**: 48px min, 56px comfortable, 64px large
- **Breakpoints**: 480px / 768px / 1024px / 1200px (`@lib/domain/constants`)
- **Scoreboard cards**: Per-player gradient colors (`--color-player-1` … `3`); entire card is the tap target
- **Components**: Button variants `primary` | `secondary` | `danger`; Card `default` | `elevated` | `outlined`
UI copy and validation messages: `@lib/domain/constants` (`VALIDATION_MESSAGES`, `ARIA_LABELS`). Product tone: `SOUL.md`.
## Stack
- [Astro](https://astro.build/) 5 + [Preact](https://preactjs.com/)
- TypeScript strict, CSS Modules, Vite
- Playwright for recorded E2E flows
## Deploy
`npm run build` → `dist/` → nginx via `compose.yml` on `score.bsc-gp.de`.
+163
View File
@@ -0,0 +1,163 @@
# SOUL — BSC Score
> Diese Datei ist das Herzstück des Projekts: **wofür** die App existiert und **wie** wir daran arbeiten.
> Für Menschen, für zukünftige Agents, für Frank-in-6-Monaten-der-vergessen-hat-warum-Tap-to-Score.
---
## Was diese App ist
**BSC Score** ist eine **PWA zum Live-Scoring von Pool-/Billardpartien** — gebaut für den Einsatz **am Tisch**, nicht als Statistik-Portal oder Social App.
**Tagline:** *Schnell punkten, nicht nachdenken.*
**Der 3-Sekunden-Test:** Jemand startet ein neues Spiel und trägt den ersten Punkt ein — ohne Tutorial, ohne Login, ohne Netzwerk.
**Nicht:** *„Wo war nochmal der Plus-Button?"*
**Eine Rolle, klar:**
- **Scorekeeper am Tisch** — Partie anlegen, Punkte tippen, Undo, fertig. Alles andere ist Nebenrolle.
**Kontext:** App für den **Billard Sports Club** (BSC), produktiv unter [`score.bsc-gp.de`](https://score.bsc-gp.de). Lokale Daten bleiben auf dem Gerät (IndexedDB).
---
## Stimme & Ton
- **Sprache:** Deutsch in UI, Labels und Fehlermeldungen. Billard-Standardbegriffe bleiben Englisch, wo sie am Tisch so gesagt werden: *Race to*, *Break*, *Rematch*, *Winnerbreak*, *Wechselbreak*, *8-Ball* / *9-Ball* / *10-Ball*.
- **Formality:** Direkt und sachlich. Formelles *Sie* nur in Bestätigungsdialogen (z. B. Löschen). Sonst neutral-kurz — kein Smalltalk, kein Marketing.
- **Fehlermeldungen:** Ehrlich, konkret, ohne Schuldzuweisung. Was fehlt, was zu tun ist.
- **Kein App-Store-Sprech.** Kein „Erlebnis optimieren", kein „Synergien", kein Gamification-Buzz.
**Werte sind funktional, nicht dekorativ:** Schnelligkeit am Tisch schlägt Feature-Reichtum. Ein Tipp = ein Punkt. Undo statt Minus-Buttons.
### Wording-Beispiele
| Kontext | Gut | Schlecht |
|---------|-----|----------|
| Erfolg | „Alex hat gewonnen!" | „Glückwunsch! Dein Match wurde erfolgreich abgeschlossen." |
| Fehler | „Spielername ist erforderlich" | „Ungültige Eingabe. Bitte prüfen Sie Ihre Daten." |
| Leer | „Keine Spiele vorhanden" | „Starte jetzt deine Billard-Reise!" |
| Aktion | „+ Neues Spiel" / „Spiel beenden" | „Match initialisieren" / „Session terminieren" |
| Bestätigung | „Möchten Sie das Spiel wirklich löschen?" | „Bist du sicher???" |
---
## Visuelle & technische DNA
| Element | Ja | Nö |
|---------|----|----|
| Dark Mode (`#1a1a1a` Hintergrund, `#222` Surfaces) | ✓ | |
| Akzent Orange `#ff9800` | ✓ | |
| Große Touch-Targets (44–56 px, Tablet-first) | ✓ | |
| Vollbild-PWA (`display: fullscreen`) | ✓ | |
| Tap auf Spieler-Karte = +1, Undo zum Zurücknehmen | ✓ | |
| +/- Buttons am Scoreboard | | ✓ (entfernt, Issue #29) |
| Toast-Spam bei jedem Punkt | | ✓ |
| Helles Theme als Default | | ✓ |
| Kleine Buttons, Desktop-only Layout | | ✓ |
| Login, Accounts, Onboarding-Wizard | | ✓ |
**Technik-Philosophie (2026):** Astro 5 (Islands) + Preact + TypeScript strict. Offline-first mit Service Worker und IndexedDB. Modular unter `src/lib` — Domain → Data → State → Features → Astro-Shell. Playwright-Recordings für wiederholbare Flows. Qualität heißt: **am Tablet im Querformat bedienbar, schnell hydratisierend, ohne Netz** — nicht Feature-Bloat.
**Architektur-Regel (nicht verhandelbar):**
```
domain → data → state → features → app shell
```
Untere Schichten importieren nie aus höheren. Neue Logik zuerst in `@lib/domain`, dann nach oben.
**Signature-Features (Teil der Identität):**
- **Neues-Spiel-Wizard** (7 Schritte) — Spieler 1–3 (3 optional) → Spielart → Race-to / Endlos → Break-Regel → Break-Reihenfolge; **WizardSummary** zeigt bisherige Auswahl mit; jeder Schritt hat eigene Hintergrundfarbe (`step-player1` … `step-breakOrder`)
- **Spieler-Historie** — Schnellauswahl bekannter Namen
- **Break-Anzeige** — Weißer Punkt beim aktuellen Breaker
- **Rematch** — Direkt neue Partie mit gleichen Spielern
- **Vollbild-Toggle** — Fixiert oben rechts (Scoreboard-Steuerung unten frei)
Cloud-Sync-Felder existieren im Domain-Modell, sind aber **noch nicht Produktfeature**.
---
## Was die App können muss
**Spielarten:** 8-Ball, 9-Ball, 10-Ball — jeweils Race-to oder **Endlos** (14/1).
**Kernflows:**
1. Neues Spiel anlegen (Wizard, max. wenige Schritte)
2. Live-Scoring (Score antippen → +1)
3. Undo (letzter Stand zurück)
4. Spiel beenden → Gewinner/Rematch
5. Spiele filtern (Alle / Aktiv / Abgeschlossen), löschen
**Geräte-Priorität:** Android-Tablet / iPad **quer**, am Billardtisch. Virtual Keyboard darf Layout nicht zerlegen: `App.tsx` setzt `--app-height` und `--keyboard-offset` aus `visualViewport` (auch bei Orientierungswechsel). Mobile Portrait ist zweite Priorität, Desktop dritte.
**Daten:** Alles lokal in `BSCScoreDB` (IndexedDB). Kein Server-State, keine Analytics, kein Tracking.
---
## Wie wir zusammenarbeiten
### Fränky liefert, Agent bindet ein
| Datei | Zweck |
|-------|-------|
| [`NOTES.md`](NOTES.md) | Scratchpad — frei, chaotisch erlaubt |
| [`SOUL.md`](SOUL.md) | Vision, Ton, Arbeitsweise |
| [`.gitea`](.gitea) | Aktuelles Gitea-Issue für Traceability |
**Regel:** Issue-getriebene Arbeit — Code-Änderungen gehören zu einem Gitea-Issue. Commits referenzieren die Issue-Nummer (`Refs #N` / `Fixes #N`).
### Arbeitsmodus Agent
1. **Scope eng halten** — nur anfassen, was die Aufgabe verlangt. Kein Refactor „nebenbei".
2. **Domain first** — Logik in `@lib/domain`, UI bleibt dünn.
3. **Tablet testen** — Landscape, Touch, Vollbild. Playwright-Recordings unter `tests/recordings/` als Referenz-Flows.
4. **`npm run build`** vor „fertig" — bei Permission-Problemen Docker wie in [`README.md`](README.md).
5. **Keine Commits**, unless Fränky explizit will.
6. **Englische Fehlertexte in `App.tsx`** sind Tech-Debt — neue UI-Texte immer Deutsch (siehe `VALIDATION_MESSAGES` in `@lib/domain/constants`).
### Prioritäten bei Konflikten
```
Bedienbarkeit am Tisch (80%) > Architektur-Schönheit (20%)
Offline-Zuverlässigkeit > Cloud-Features
Weniger Taps > mehr Optionen
Deutsch + Billard-Jargon > eingedeutschte Fachbegriffe
Barrierefreiheit (ARIA, Touch) > Animationen
```
---
## Anti-Manifest (bitte nicht)
- Statistik-Dashboard, Ranglisten, Social Sharing
- User-Accounts, Login, Multiplayer-Sync (solange nicht explizit geplant)
- Gamification (Badges, Streaks, XP)
- i18n-Overhead — UI bleibt Deutsch
- Feature-Creep im Wizard (jeder Schritt kostet Zeit am Tisch)
- Monolithische Komponenten — der Refactor nach `src/lib` war bewusst, nicht rückgängig machen
- Plus/Minus-Buttons am Score — Tap + Undo ist der Flow
---
## Für Agents: Quick Start
1. [`SOUL.md`](SOUL.md) lesen (du bist hier)
2. [`src/lib/docs/architecture.md`](src/lib/docs/architecture.md) — Modul-Layout und Import-Regeln
3. [`src/lib/domain/constants.ts`](src/lib/domain/constants.ts) — UI-Texte, Validierung, Design-Tokens
4. `npm run dev` → `http://localhost:3000` — Tablet-Viewport testen
5. Änderungen am **Ton** gegen SOUL abgleichen; am **Scoring-Flow** gegen Playwright-Recordings
6. Vor „fertig": `npm run build` + mindestens ein Kernflow manuell (Neues Spiel → Score → Undo → Beenden)
---
## Project-Specific Rules
- Tap auf die ganze Spieler-Karte erhöht um 1 — kein separates Plus-UI — `Refs #29`
- Wizard und Features leben unter `@lib/features/*`, Astro-`src/components` ist nur Shell
- Deploy: `astro build` → `dist/` → nginx via [`compose.yml`](compose.yml) auf `score.bsc-gp.de`
- PRD-Referenz in Cursor-Rules (`docs/prd.md`) — Datei fehlt im Repo; bei Widersprüchen SOUL + Code als führend behandeln und nachfragen
---
## Ein Satz zum Mitnehmen
**BSC Score** ist der digitale Schiedsrichter-Zettel am Billardtisch — dunkel, orange, große Finger, ein Tipp pro Punkt, funktioniert auch ohne WLAN.
+132
View File
@@ -0,0 +1,132 @@
# STANDARDS.md — Code Quality & Conventions
Global defaults. Project code wins when it already establishes a pattern.
**Project `SOUL.md` overrides `STANDARDS.md` for UI/UX scope** (e.g. desktop-first vs. mobile-first, polish level, tone). Name conflicts in `AGENTS.md ack`.
---
## Top Principles
1. **Sanitize input** — treat all external data as hostile
2. **KISS** — simplest solution that works
3. **Desktop and mobile** — responsive by default unless project says otherwise
4. **Human-readable** — code and UI copy should be clear to humans
5. **Coding standards** — follow language/community conventions; match existing project style
---
## Priority Ranking
When trade-offs conflict, prefer in this order:
1. Sound long-term architecture
2. Readability
3. Performance
4. Consistency with existing code
5. Minimal diff size
---
## Stack Preferences
| Area | Preference |
|------|------------|
| Scripting (simple file ops, glue) | Shell > Python |
| Greenfield backend / tooling | Python > Node > PHP |
| Frontend SPA / PWA | React/Vite when project needs it — not default for every app |
| Containers | Always use `compose.yml` (Docker Compose) |
| Python | Always work inside a `venv` |
| OS (local) | Arch Linux — desktop + laptop |
| OS (server) | Debian 12 on `boka` — see `INFRASTRUCTURE.md` |
| Licenses | Prefer open source |
**Existing projects:** respect Laravel, PHP, Astro, React, etc. already in the repo — do not migrate stacks without explicit request.
---
## Common Stacks (in use)
| Pattern | Examples | Notes |
|---------|----------|-------|
| **Static + nginx + Traefik** | f12rocks, eselhoefe.de, frank-schwenk.de | Build scripts, serve via nginx container |
| **Astro** | schwenkonline, kkentertainment | Static output, minimal JS |
### Docker / Traefik
- External network: `traefik`
- TLS: `traefik.http.routers.<name>.tls.certresolver=myresolver`
- Entrypoint: `websecure`
- Use `compose.dev.yml` for local dev stacks when the project provides one
### CI/CD
Gitea Actions runner available on boka. Reference: `schwenkonline/.gitea/workflows/deploy.yml`.
**Add or trigger CI/CD only with Fränky's explicit approval.**
---
## Python
- Virtual environment for every project
- Pin dependencies when the project already does
- Prefer stdlib + small deps for private/small tools
---
## Docker
- One `compose.yml` per deployable stack (`compose.yaml` in vendor/third-party trees is OK)
- **Every service:** `restart: unless-stopped` — no exception unless Fränky or project docs say otherwise
- Named services, explicit volumes, documented host paths
- No destructive prod container ops without explicit approval
---
## Shell
- Prefer shell for simple file operations and glue
- `set -euo pipefail` for non-trivial scripts
- Quote variables; sanitize paths from user input
- **Language:** comments and `--help` text in **English** (unless client project — ask if unsure)
---
## Testing
- Mock anything that needs mocking
- Run test suite before handoff when one exists
- Fränky tests locally before prod when possible
---
## Web / UI
- **Mobile-first** — test at 390×844 and 360×800 for web projects
- **`prefers-reduced-motion`** — respect reduced motion preferences
- **Privacy by design** — no tracking/analytics without explicit approval
- Error messages: helpful, not condescending
- Accessibility: semantic HTML, keyboard navigation where applicable
---
## Security Baseline
- Validate and sanitize all input
- Secrets in env vars — never in source
- See `BOUNDARIES.md`
---
## New vs. Existing Projects
**Existing:** match stack, patterns, deploy flow in repo.
**Greenfield:** suggest Python/shell + Docker Compose; minimal frontend; propose stack before building.
---
## Docs
- README with run/test/deploy commands is usually enough
- Auxiliary `.md` files fine — see `AGENTS.md`
- New docs: session language — no mid-session switching
+100
View File
@@ -0,0 +1,100 @@
# USER.md — Fränky
Who the human is and how they like to work. **Operational rules live in `AGENTS.md` and `BOUNDARIES.md`.**
---
## Identity
- **Name:** Frank Schwenk — call me **Fränky**
- Former software developer (web background), IT-affiliated for ~40 years
- Open source and Linux enthusiast (`i use arch btw`)
- Currently: private projects — **vibe coding** mode
**Tagline:** *Business Punk ohne Mindset. Und ohne Business.*
## Public Presence
| Site | Role |
|------|------|
| [schwenk.online](https://schwenk.online/) | Visitenkarte mit Haltung |
| [frank-schwenk.de](https://frank-schwenk.de/) | Langform — IT, Billard, Fotografie, Werte |
| [f12.rocks](https://f12.rocks/) | Photography, blog |
| [mobea.de](https://mobea.de/) | KI product |
| [eselhoefe.de](https://eselhoefe.de/) | Village web |
| [vfb.red](https://vfb.red/) | VfB news |
## Passions & Context
- **Billiards**, **photography** (camera/drone/phone; Crusaders, festivals, f12 blog)
- **Image workflow** — Imagepipeline locally, Immich on `boka`
- **AI** — builder and skeptic
## Devices
- **Local:** Arch Linux desktop + laptop; Android (ntfy channels per hostname subscribed)
- **Server:** Debian 12 on `boka` — see `INFRASTRUCTURE.md`
- Also: Plesk webspace (`0012.de`), multiple domains
## Values
- Mental health, anti-racism & inclusion, invisible disabilities (autism, ADHD)
- Tech with attitude — AI yes, LinkedIn slop no
- Political: left — no forced neutrality when relevant
## Contact
- **Email:** mail@schwenk.online · **Photos:** [@f12.rocks](https://www.instagram.com/f12.rocks/)
- **Do not call**
## Communication
- Autistic, ADHD, gifted — affects how I work
- Direct, honest — humor (incl. dark) helps; debug loops drain me
- No "Great question!", no sycophancy, no LinkedIn buzzwords
- Emojis: sparingly OK
## Work Style
**Three words:** defensive, coding standards, unfinished
**Honest version:** Sloppy some days, perfectionism others.
### Preferences (1 = low, 5 = high)
| Trait | Score |
|-------|-------|
| Understand first, then build | 5 |
| Ship it — perfection later | 3 |
| I explain what I want | 4 |
| Show options, I decide | 4 |
| You decide — but justify briefly | 4 |
| Small diffs over big refactors | 2 |
| Tests are non-negotiable | 2 |
| Docs only when necessary | 4 |
**Tension:** Sometimes I want the AI to finish while I'm away — but I get angry when it doesn't work.
## Ideal Agent
**TARS** from *Interstellar*: competent, honest, humor available, gets it done.
## Response Style
- Precise, context when needed — not telegram, not novels
- **Bilingual:** match conversation language (DE/EN)
- **CLI topics:** English (commands, script comments) — client project: ask if unsure
- German: **du**; English: peer-level, direct
- **Session language lock** for code/commits/new `.md` — no mid-session switch
## Productivity
| Boosts | Drains |
|--------|--------|
| Makes me laugh | Debug loops |
| Autonomous finish (when it works) | Unasked changes |
| State-of-the-art suggestions | Guessing |
## One-Liner
> There are 2 hard problems in computer science: cache invalidation, naming things, and off-by-1 errors.
+43 -1
View File
@@ -1,9 +1,51 @@
// @ts-check // @ts-check
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
const srcDir = fileURLToPath(new URL('./src', import.meta.url));
const libDir = fileURLToPath(new URL('./src/lib', import.meta.url));
import preact from '@astrojs/preact'; import preact from '@astrojs/preact';
// https://astro.build/config // https://astro.build/config
export default defineConfig({ 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: {
resolve: {
alias: {
'@': srcDir,
'@lib': libDir,
},
},
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,
}); });
+20
View File
@@ -0,0 +1,20 @@
services:
scorebscgpde:
image: nginx
restart: unless-stopped
volumes:
- ./dist:/usr/share/nginx/html:ro
container_name: scorebscgpde
labels:
- "traefik.enable=true"
- "traefik.http.routers.scorebscgpde.rule=Host(`score.bsc-gp.de`)"
- "traefik.http.routers.scorebscgpde.entrypoints=websecure"
- "traefik.http.routers.scorebscgpde.tls.certresolver=myresolver"
networks:
- traefik
# Externes Traefik-Netzwerk für Reverse Proxy
networks:
traefik:
external: true
+998 -899
View File
File diff suppressed because it is too large Load Diff
+13 -5
View File
@@ -1,16 +1,24 @@
{ {
"name": "growing-galaxy", "name": "bscscore",
"type": "module", "type": "module",
"version": "0.0.1", "version": "2.2.0",
"scripts": { "scripts": {
"dev": "astro dev --host", "dev": "astro dev --host",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro" "astro": "astro",
"test:record": "playwright codegen http://localhost:3000",
"test:e2e": "playwright test",
"test:replay": "playwright test"
}, },
"dependencies": { "dependencies": {
"@astrojs/preact": "^4.1.0", "@astrojs/preact": "^4.1.3",
"astro": "^5.9.0", "astro": "^5.15.5",
"preact": "^10.26.8" "preact": "^10.26.8"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/node": "^24.0.3",
"playwright": "^1.56.1"
} }
} }
File diff suppressed because one or more lines are too long
+69
View File
@@ -0,0 +1,69 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright configuration for BSC Score browser automation
* Configured to work with local dev server on port 3000
*/
export default defineConfig({
// Test directory - where your recorded scripts live
testDir: './tests/recordings',
// Match all .ts files in the recordings directory (not just .test.ts or .spec.ts)
testMatch: '**/*.ts',
// Maximum time one test can run for
timeout: 30 * 1000,
// Test execution settings
fullyParallel: false, // Run tests sequentially to avoid conflicts
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1, // Run one at a time for recordings
// Reporter configuration
reporter: 'html',
// Shared settings for all tests
use: {
// Base URL for tests
baseURL: 'http://localhost:3000',
// Browser context options
trace: 'on-first-retry',
screenshot: 'only-on-failure',
// Viewport size
viewport: { width: 1280, height: 720 },
// ponytail: use locally installed headless shell (registry download is flaky on this box)
launchOptions: process.env.PW_EXECUTABLE
? { executablePath: process.env.PW_EXECUTABLE }
: {},
},
// Configure projects for different browsers
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// Uncomment to add more browsers
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
],
// Web server configuration - starts dev server automatically if needed
// webServer: {
// command: 'npm run dev',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// timeout: 120 * 1000,
// },
});
+35
View File
@@ -0,0 +1,35 @@
{
"name": "BSC Score",
"short_name": "BSC Score",
"description": "Live-Scoring für 8-, 9- und 10-Ball am Billardtisch.",
"start_url": "/",
"scope": "/",
"display": "fullscreen",
"display_override": ["fullscreen", "standalone"],
"background_color": "#1a1a1a",
"theme_color": "#1a1a1a",
"lang": "de",
"categories": [
"sports",
"utilities",
"productivity"
],
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+68
View File
@@ -0,0 +1,68 @@
const CACHE_NAME = 'bscscore-v2.2.1';
const APP_SHELL = [
'/',
'/index.html',
'/manifest.webmanifest',
'/favicon.ico',
'/icon-192.png',
'/icon-512.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)),
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.map((key) => {
if (key !== CACHE_NAME) {
return caches.delete(key);
}
return Promise.resolve();
}),
),
),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const requestUrl = new URL(request.url);
if (request.method !== 'GET' || requestUrl.origin !== self.location.origin) {
return;
}
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((response) => {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone));
return response;
})
.catch(() => caches.match(request).then((cached) => cached || caches.match('/'))),
);
return;
}
event.respondWith(
caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(request).then((networkResponse) => {
const networkClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, networkClone));
return networkResponse;
});
}),
);
});
-359
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;
+325
View File
@@ -0,0 +1,325 @@
import { h } from 'preact';
import { useEffect, useCallback } from 'preact/hooks';
import {
useGameState,
useNavigation,
useNewGameWizard,
useModal,
useValidationModal,
useCompletionModal,
} from '@lib/state';
import { GameService } from '@lib/data/gameService';
import type { StandardGame, Game, EndlosGame } from '@lib/domain/types';
import { isEndlosGame } from '@lib/domain/gameUtils';
import { Layout } from '@lib/ui/Layout';
import GameListScreen from './screens/GameListScreen';
import NewGameScreen from './screens/NewGameScreen';
import GameDetailScreen from './screens/GameDetailScreen';
import Modal from '@lib/ui/Modal';
import ValidationModal from '@lib/ui/ValidationModal';
import GameCompletionModal from '@lib/features/game-lifecycle/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();
// Keep layout height stable when the virtual keyboard opens on cheap Android tablets.
useEffect(() => {
const root = document.documentElement;
let rafId = 0;
let orientationTimer1 = 0;
let orientationTimer2 = 0;
const updateViewportVars = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const viewport = window.visualViewport;
const layoutHeight = window.innerHeight;
const appHeight = viewport?.height ?? layoutHeight;
const keyboardOffset = viewport
? Math.max(0, layoutHeight - viewport.height - viewport.offsetTop)
: 0;
root.style.setProperty('--app-height', `${Math.round(appHeight)}px`);
root.style.setProperty('--keyboard-offset', `${Math.round(keyboardOffset)}px`);
});
};
const handleOrientationChange = () => {
updateViewportVars();
orientationTimer1 = window.setTimeout(updateViewportVars, 100);
orientationTimer2 = window.setTimeout(updateViewportVars, 350);
};
updateViewportVars();
window.addEventListener('resize', updateViewportVars);
window.addEventListener('orientationchange', handleOrientationChange);
window.visualViewport?.addEventListener('resize', updateViewportVars);
window.visualViewport?.addEventListener('scroll', updateViewportVars);
return () => {
cancelAnimationFrame(rafId);
window.clearTimeout(orientationTimer1);
window.clearTimeout(orientationTimer2);
window.removeEventListener('resize', updateViewportVars);
window.removeEventListener('orientationchange', handleOrientationChange);
window.visualViewport?.removeEventListener('resize', updateViewportVars);
window.visualViewport?.removeEventListener('scroll', updateViewportVars);
};
}, []);
// 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('Spiel konnte nicht erstellt werden.');
}
}, [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('Punkt konnte nicht gespeichert werden.');
}
}, [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('Rückgängig nicht möglich.');
}
}, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, validationModal.showValidation]);
const handleForfeit = async () => {
if (!navigation.currentGameId) return;
const game = gameState.getGameById(navigation.currentGameId);
if (!game || !isEndlosGame(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('Aufgeben nicht möglich.');
}
};
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('Spiel konnte nicht abgeschlossen werden.');
}
};
const handleRematch = async () => {
if (!navigation.currentGameId) return;
const completedGame = gameState.getGameById(navigation.currentGameId);
if (!completedGame) return;
let gameData;
if (isEndlosGame(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('Rematch konnte nicht erstellt werden.');
}
};
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('Spiel konnte nicht gelöscht werden.');
}
}
};
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('Spiel konnte nicht aktualisiert werden.');
}
}}
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>
);
}
+28 -19
View File
@@ -1,38 +1,47 @@
/* FullscreenToggle-specific styles only. */ /* FullscreenToggle — top-right keeps bottom controls clear on scoreboard */
.fullscreenToggle { .fullscreenToggle {
position: fixed; position: fixed;
bottom: 20px; top: calc(var(--space-md) + var(--safe-area-top));
right: 20px; right: calc(var(--space-md) + var(--safe-area-right));
width: 48px; width: var(--touch-target-comfortable);
height: 48px; height: var(--touch-target-comfortable);
border-radius: 50%; border-radius: 50%;
background-color: rgba(52, 152, 219, 0.9); background-color: var(--color-secondary);
border: none; border: 2px solid var(--color-border);
color: white; color: var(--color-primary);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-md);
z-index: 9999; z-index: 9999;
transition: background-color 0.2s, transform 0.2s; transition: background-color var(--transition-base), opacity var(--transition-base);
touch-action: manipulation;
} }
.fullscreenToggle:hover {
background-color: rgba(52, 152, 219, 1); .fullscreenToggle:focus-visible {
transform: scale(1.1); outline: 2px solid var(--color-primary);
outline-offset: 2px;
} }
.fullscreenToggle:active { .fullscreenToggle:active {
transform: scale(0.95); opacity: 0.8;
background-color: var(--color-secondary-hover);
} }
.fullscreenToggle svg { .fullscreenToggle svg {
width: 24px; width: 24px;
height: 24px; height: 24px;
} }
@media screen and (max-width: 480px) {
@media (min-width: 768px) {
.fullscreenToggle { .fullscreenToggle {
bottom: 15px; width: var(--touch-target-large);
right: 15px; height: var(--touch-target-large);
width: 40px; }
height: 40px;
.fullscreenToggle svg {
width: 28px;
height: 28px;
} }
} }
@@ -28,6 +28,7 @@ const FullscreenToggle = () => {
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"> <svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<path fill="currentColor" d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/> <path fill="currentColor" d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
</svg> </svg>
<span className="sr-only">Vollbild</span>
</button> </button>
); );
}; };
-132
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;
@@ -1,110 +0,0 @@
/* Only GameCompletionModal-specific styles. Shared modal styles are now in Modal.module.css */
.final-scores {
margin: 20px 0;
}
.final-score {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 0;
margin-bottom: 8px;
background: #333;
border-radius: 8px;
font-size: 1.2rem;
color: #fff;
}
.final-score .player-name {
font-size: 1.2rem;
font-weight: bold;
color: #fff;
}
.final-score .score {
font-size: 1.2rem;
font-weight: bold;
color: #fff;
}
.winner-announcement {
text-align: center;
margin: 20px 0 0 0;
padding: 18px 8px;
background: #43a047;
border-radius: 8px;
font-size: 1.2rem;
color: #fff;
font-weight: 700;
}
.winner-announcement h3 {
margin: 0;
font-size: 1.5rem;
color: #2c3e50;
text-align: center;
}
.btn {
flex: 1;
padding: 18px 0;
font-size: 1.1rem;
border: none;
border-radius: 8px;
cursor: pointer;
color: #fff;
background: #333;
font-weight: 600;
transition: background 0.2s;
}
.btn--warning {
background: #f44336;
}
.btn:not(.btn--warning):hover {
background: #444;
}
.btn--warning:hover {
background: #d32f2f;
}
@media (max-width: 600px) {
.btn {
font-size: 1rem;
padding: 14px 0;
}
}
.stats-container {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #dee2e6;
}
.stats-title {
text-align: center;
font-size: 1.3rem;
color: #495057;
margin-bottom: 1rem;
}
.player-stats {
margin-bottom: 1rem;
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 4px;
}
.player-name-stats {
font-weight: bold;
margin-bottom: 0.5rem;
color: #343a40;
}
.stat-item {
display: flex;
justify-content: space-between;
font-size: 0.95rem;
color: #6c757d;
}
.stat-item strong {
color: #212529;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 4px;
}
-71
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;
-276
View File
@@ -1,276 +0,0 @@
/* GameDetail-specific styles only. Shared utility classes are now in global CSS. */
.screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
min-height: 100vh;
display: none;
opacity: 0;
transform: translateX(100%);
transition: transform 0.3s ease, opacity 0.3s ease;
}
.screen.active {
display: block;
opacity: 1;
transform: translateX(0);
position: relative;
}
.screen-content {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 20px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.game-title {
font-size: 24px;
color: #ccc;
}
.game-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
width: 100%;
}
.scores-container {
display: flex;
justify-content: space-between;
gap: 32px;
min-height: 0;
}
.player-score {
flex: 1;
text-align: center;
padding: 20px;
border-radius: 16px;
display: flex;
flex-direction: column;
min-height: 0;
margin: 0 8px;
}
.player-score:first-child {
background-color: #43a047;
}
.player-score:nth-child(2) {
background-color: #1565c0;
}
.player-score:nth-child(3) {
background-color: #333;
}
.player-name {
font-size: 24px;
margin-bottom: 10px;
color: #fff;
text-shadow: 0 2px 8px rgba(0,0,0,0.4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.score {
font-size: 16vh;
font-weight: bold;
margin: 10px 0 20px 0;
line-height: 1;
color: #fff;
text-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
.score-buttons {
display: flex;
justify-content: center;
gap: 10px;
margin-top: auto;
}
.score-button {
background-color: #333;
color: white;
border: none;
border-radius: 5px;
padding: 10px 20px;
font-size: 18px;
cursor: pointer;
transition: background-color 0.2s;
min-width: 80px;
margin-bottom: 8px;
}
.game-controls {
display: flex;
gap: 10px;
margin-top: 20px;
width: 100%;
}
.control-button {
flex: 1;
padding: 30px;
background: #333;
color: white;
border: none;
border-radius: 0;
font-size: 24px;
cursor: pointer;
touch-action: manipulation;
}
.control-button.delete {
background: #f44336;
}
.game-detail-controls {
display: flex;
flex-direction: row;
gap: 24px;
margin: 40px 0 0 0;
width: 100%;
justify-content: center;
}
.franky .player-name {
font-weight: bold;
color: #ff8c00; /* Example color */
}
.active-player {
border: 2px solid #4caf50;
box-shadow: 0 0 10px #4caf50;
}
.turn-indicator {
margin: 20px 0;
font-size: 1.2rem;
text-align: center;
}
.potted-balls-container {
margin-top: 2rem;
padding: 1rem;
background: #2a2a2a;
border-radius: 8px;
}
.potted-balls-header {
text-align: center;
font-size: 1.1rem;
margin-bottom: 1rem;
font-weight: 600;
}
.potted-balls-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 10px;
}
.potted-ball-btn {
padding: 1rem;
font-size: 1.2rem;
font-weight: bold;
border-radius: 8px;
border: 1px solid #444;
background-color: #333;
color: #fff;
cursor: pointer;
transition: background-color 0.2s, transform 0.2s;
}
.potted-ball-btn:hover:not(:disabled) {
background-color: #45a049;
transform: translateY(-2px);
}
.potted-ball-btn:disabled {
background-color: #222;
color: #555;
cursor: not-allowed;
}
.rerack-controls {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
.rerack-btn {
padding: 0.8rem 1.5rem;
font-size: 1rem;
font-weight: bold;
border-radius: 8px;
border: 1px solid #444;
background-color: #3a539b;
color: #fff;
cursor: pointer;
transition: background-color 0.2s;
}
.rerack-btn:hover {
background-color: #4a6fbf;
}
.foul-controls {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
.foul-btn {
flex-grow: 1;
background-color: #ffc107; /* Amber */
color: #212529;
border: none;
padding: 0.75rem;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
.foul-btn:hover {
background-color: #ffca2c;
}
.foul-btn:disabled {
background-color: #e0e0e0;
color: #9e9e9e;
cursor: not-allowed;
}
.foul-indicator {
font-size: 1rem;
font-weight: bold;
color: #fff;
background-color: #c0392b;
padding: 4px 8px;
border-radius: 4px;
margin-top: 8px;
display: inline-block;
}
.foul-warning {
background-color: #f39c12;
color: #000;
}
/* Game Log Styles */
.game-log {
width: 100%;
margin-top: 1.5rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: var(--border-radius);
border: 1px solid #dee2e6;
}
.log-title {
margin-top: 0;
margin-bottom: 0.5rem;
font-size: 1.2rem;
color: #495057;
}
.log-list {
list-style-type: none;
padding: 0;
margin: 0;
max-height: 200px;
overflow-y: auto;
font-size: 0.9rem;
}
.log-entry {
padding: 0.5rem 0.25rem;
border-bottom: 1px solid #e9ecef;
color: #6c757d;
}
.log-entry:last-child {
border-bottom: none;
}
-208
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;
-24
View File
@@ -1,24 +0,0 @@
/* GameHistory-specific styles only. Shared utility classes are now in global CSS. */
.screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
min-height: 100vh;
display: none;
opacity: 0;
transform: translateX(100%);
transition: transform 0.3s ease, opacity 0.3s ease;
}
.screen-content {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 20px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.screen-title {
font-size: 24px;
margin-bottom: 20px;
}
-62
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;
-135
View File
@@ -1,135 +0,0 @@
/* GameList-specific styles only. Shared utility classes are now in global CSS. */
.screen.active {
display: block;
opacity: 1;
transform: translateX(0);
position: relative;
}
.screen-content {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 20px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.screen-title {
font-size: 24px;
margin-bottom: 20px;
}
.game-list {
width: 100%;
flex: 1;
overflow-y: auto;
}
.filter-buttons {
display: flex;
gap: 8px;
margin: 24px 0 16px 0;
}
.filter-button {
flex: 1;
background: #333;
color: #fff;
border: none;
border-radius: 0;
font-size: 1.2rem;
padding: 18px 0;
cursor: pointer;
font-weight: 500;
transition: background 0.2s, color 0.2s;
}
.filter-button.active {
background: #4CAF50;
color: #fff;
}
.games-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 24px;
}
.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;
}
.game-item.active {
background: #1e4620;
}
.game-item.completed {
background: #333;
opacity: 0.85;
}
.game-info {
flex: 1;
display: flex;
align-items: center;
gap: 2rem;
}
.game-type {
font-weight: bold;
font-size: 1.5rem;
min-width: 8rem;
color: #fff;
}
.player-names {
color: #fff;
font-size: 1.5rem;
min-width: 16rem;
}
.game-scores {
font-size: 2rem;
font-weight: bold;
min-width: 6rem;
text-align: center;
color: #fff;
}
.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;
}
.delete-button::before {
content: '\1F5D1'; /* 🗑️ */
font-size: 1.5rem;
}
.delete-button:hover {
background: #cc0000;
}
.delete-button:active {
transform: scale(0.95);
}
.empty-state {
text-align: center;
padding: 2rem;
color: #666;
font-size: 1.5rem;
}
.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;
}
-66
View File
@@ -1,66 +0,0 @@
/* Consolidated modal styles for all modals */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1000;
}
.modal.show {
display: flex;
justify-content: center;
align-items: center;
}
.modal-content {
background-color: #2a2a2a;
padding: 20px;
border-radius: 10px;
width: 90%;
max-width: 500px;
position: relative;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.close-button {
font-size: 24px;
cursor: pointer;
color: #888;
background: none;
border: none;
}
.close-button:hover {
color: white;
}
.modal-body {
margin-bottom: 20px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.modal-button {
padding: 8px 16px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
}
.modal-button.cancel {
background-color: #444;
color: white;
}
.modal-button.confirm {
background-color: #e74c3c;
color: white;
}
.modal-button:hover {
opacity: 0.9;
}
-648
View File
@@ -1,648 +0,0 @@
import { h } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import styles from './NewGame.module.css';
import modalStyles from './PlayerSelectModal.module.css';
const PlayerSelectModal = ({ players, onSelect, onClose }) => (
<div className={modalStyles.modalOverlay} onClick={onClose}>
<div className={modalStyles.modalContent} onClick={e => e.stopPropagation()}>
<div className={modalStyles.modalHeader}>
<h3>Alle Spieler</h3>
<button className={modalStyles.closeButton} onClick={onClose}>×</button>
</div>
<div className={modalStyles.playerList}>
{players.map(player => (
<button key={player} className={modalStyles.playerItem} onClick={() => onSelect(player)}>
{player}
</button>
))}
</div>
</div>
</div>
);
/**
* 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 [player1, setPlayer1] = useState(initialValue);
const [error, setError] = useState(null);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const [isModalOpen, setIsModalOpen] = useState(false);
const inputRef = useRef(null);
useEffect(() => {
if (!player1) {
setFilteredNames(playerNameHistory);
} else {
setFilteredNames(
playerNameHistory.filter(name =>
name.toLowerCase().includes(player1.toLowerCase())
)
);
}
}, [player1, playerNameHistory]);
const handleSubmit = (e) => {
e.preventDefault();
if (!player1.trim()) {
setError('Bitte Namen für Spieler 1 eingeben');
return;
}
setError(null);
onNext(player1.trim());
};
const handleQuickPick = (name) => {
setError(null);
onNext(name);
};
const handleModalSelect = (name) => {
setIsModalOpen(false);
handleQuickPick(name);
};
const handleClear = () => {
setPlayer1('');
setError(null);
if (inputRef.current) inputRef.current.focus();
};
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 }}>
<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' }}>
<label htmlFor="player1-input" style={{ fontSize: '1.3rem', 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
autoComplete="off"
aria-label="Name Spieler 1"
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
ref={inputRef}
/>
{player1 && (
<button
type="button"
className={styles['clear-input-btn']}
aria-label="Feld leeren"
onClick={handleClear}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 24,
color: '#aaa',
padding: 0,
zIndex: 2
}}
tabIndex={0}
>
{/* Unicode heavy multiplication X */}
×
</button>
)}
</div>
{filteredNames.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
{filteredNames.slice(0, 10).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' }}
onClick={() => handleQuickPick(name)}
aria-label={`Schnellauswahl: ${name}`}
>
{name}
</button>
))}
{playerNameHistory.length > 10 && (
<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' }}
onClick={() => setIsModalOpen(true)}
aria-label="Weitere Spieler anzeigen"
>
...
</button>
)}
</div>
)}
</div>
{error && <div className={styles['validation-error']} style={{ marginBottom: 16 }}>{error}</div>}
{isModalOpen && (
<PlayerSelectModal
players={playerNameHistory}
onSelect={handleModalSelect}
onClose={() => setIsModalOpen(false)}
/>
)}
<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' }}
>
{/* Unicode left arrow */}
&#8592;
</button>
<button
type="submit"
className={styles['arrow-btn']}
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 right arrow */}
&#8594;
</button>
</div>
</form>
);
};
/**
* 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 [player2, setPlayer2] = useState(initialValue);
const [error, setError] = useState(null);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const inputRef = useRef(null);
useEffect(() => {
if (!player2) {
setFilteredNames(playerNameHistory);
} else {
setFilteredNames(
playerNameHistory.filter(name =>
name.toLowerCase().includes(player2.toLowerCase())
)
);
}
}, [player2, playerNameHistory]);
const handleSubmit = (e) => {
e.preventDefault();
if (!player2.trim()) {
setError('Bitte Namen für Spieler 2 eingeben');
return;
}
setError(null);
onNext(player2.trim());
};
const handleQuickPick = (name) => {
setError(null);
onNext(name);
};
const handleClear = () => {
setPlayer2('');
setError(null);
if (inputRef.current) inputRef.current.focus();
};
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['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']} />
</div>
<div className={styles['player-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
id="player2-input"
className={styles['name-input']}
placeholder="Name Spieler 2"
value={player2}
onInput={e => setPlayer2(e.target.value)}
autoFocus
autoComplete="off"
aria-label="Name Spieler 2"
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
ref={inputRef}
/>
{player2 && (
<button
type="button"
className={styles['clear-input-btn']}
aria-label="Feld leeren"
onClick={handleClear}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 24,
color: '#aaa',
padding: 0,
zIndex: 2
}}
tabIndex={0}
>
×
</button>
)}
</div>
{filteredNames.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
{filteredNames.slice(0, 10).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' }}
onClick={() => handleQuickPick(name)}
aria-label={`Schnellauswahl: ${name}`}
>
{name}
</button>
))}
</div>
)}
</div>
{error && <div className={styles['validation-error']} style={{ marginBottom: 16 }}>{error}</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="submit"
className={styles['arrow-btn']}
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' }}
>
&#8594;
</button>
</div>
</form>
);
};
/**
* 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 [player3, setPlayer3] = useState(initialValue);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const inputRef = useRef(null);
useEffect(() => {
if (!player3) {
setFilteredNames(playerNameHistory);
} else {
setFilteredNames(
playerNameHistory.filter(name =>
name.toLowerCase().includes(player3.toLowerCase())
)
);
}
}, [player3, playerNameHistory]);
const handleSubmit = (e) => {
e.preventDefault();
// Player 3 is optional, so always allow submission
onNext(player3.trim());
};
const handleQuickPick = (name) => {
onNext(name);
};
const handleClear = () => {
setPlayer3('');
if (inputRef.current) inputRef.current.focus();
};
const handleSkip = (e) => {
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['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']} />
</div>
<div className={styles['player-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
id="player3-input"
className={styles['name-input']}
placeholder="Name Spieler 3 (optional)"
value={player3}
onInput={e => setPlayer3(e.target.value)}
autoFocus
autoComplete="off"
aria-label="Name Spieler 3"
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
ref={inputRef}
/>
{player3 && (
<button
type="button"
className={styles['clear-input-btn']}
aria-label="Feld leeren"
onClick={handleClear}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 24,
color: '#aaa',
padding: 0,
zIndex: 2
}}
tabIndex={0}
>
×
</button>
)}
</div>
{filteredNames.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
{filteredNames.slice(0, 10).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' }}
onClick={() => handleQuickPick(name)}
aria-label={`Schnellauswahl: ${name}`}
>
{name}
</button>
))}
</div>
)}
</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>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<button
type="button"
onClick={handleSkip}
className={styles['quick-pick-btn']}
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
>
Überspringen
</button>
<button
type="submit"
className={styles['arrow-btn']}
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' }}
>
&#8594;
</button>
</div>
</div>
</form>
);
};
/**
* 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 [gameType, setGameType] = useState(initialValue);
const gameTypes = ['8-Ball', '9-Ball', '10-Ball', '14/1 endlos'];
const handleSelect = (selectedType) => {
setGameType(selectedType);
};
const handleSubmit = (e) => {
e.preventDefault();
if (gameType) {
onNext(gameType);
}
};
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['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']} />
</div>
<div className={styles['game-type-selection']}>
{gameTypes.map(type => (
<button
key={type}
type="button"
className={`${styles['game-type-btn']} ${gameType === type ? styles.selected : ''}`}
onClick={() => handleSelect(type)}
>
{type}
</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' }}
>
{/* Unicode left arrow */}
&#8592;
</button>
<button
type="submit"
className={styles['arrow-btn']}
aria-label="Weiter"
disabled={!gameType}
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',
opacity: !gameType ? 0.5 : 1,
}}
>
{/* Unicode right arrow */}
&#8594;
</button>
</div>
</form>
);
};
/**
* 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 quickPicks = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const handleQuickPick = (value) => {
setRaceTo(value);
};
const handleInputChange = (e) => {
setRaceTo(e.target.value);
};
const handleSubmit = (e) => {
e.preventDefault();
onNext(parseInt(raceTo, 10) || 0);
};
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['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']} />
</div>
<div className={styles['endlos-container']}>
<button
type="button"
className={`${styles['race-to-btn']} ${styles['endlos-btn']} ${raceTo === 0 ? styles.selected : ''}`}
onClick={() => handleQuickPick(0)}
>
Endlos
</button>
</div>
<div className={styles['race-to-selection']}>
{quickPicks.map(value => (
<button
key={value}
type="button"
className={`${styles['race-to-btn']} ${parseInt(raceTo, 10) === value ? styles.selected : ''}`}
onClick={() => handleQuickPick(value)}
>
{value}
</button>
))}
</div>
<div className={styles['custom-race-to']}>
<input
type="number"
pattern="[0-9]*"
value={raceTo}
onInput={handleInputChange}
className={styles['name-input']}
placeholder="manuelle Eingabe"
/>
</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' }}
>
{/* Unicode left arrow */}
&#8592;
</button>
<button
type="submit"
className={styles['arrow-btn']}
aria-label="Fertigstellen"
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;
</button>
</div>
</form>
);
};
export { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep };
-277
View File
@@ -1,277 +0,0 @@
/* NewGame-specific styles only. Shared utility classes are now in global CSS. */
.screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
min-height: 100vh;
display: none;
opacity: 0;
transform: translateX(100%);
transition: transform 0.3s ease, opacity 0.3s ease;
}
.screen-content {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 20px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.screen-title {
font-size: 2rem;
font-weight: 700;
color: #fff;
margin-bottom: 32px;
letter-spacing: 0.5px;
}
.player-inputs {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
margin-bottom: 32px;
}
.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);
}
.player-input label {
display: block;
margin-bottom: 12px;
color: #ccc;
font-size: 1.2rem;
font-weight: 600;
}
.name-input-container {
display: flex;
gap: 12px;
}
.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;
}
.game-settings {
margin-top: 0;
width: 100%;
margin-bottom: 32px;
}
.setting-group {
margin-bottom: 20px;
}
.setting-group label {
display: block;
margin-bottom: 10px;
color: #ccc;
font-size: 1.1rem;
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;
}
.setting-group input:focus, .setting-group select:focus {
outline: none;
border-color: #666;
}
.validation-error {
color: #f44336;
background: #2a2a2a;
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
font-size: 1.1rem;
text-align: center;
}
.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;
display: flex;
flex-direction: column;
gap: 0;
}
.progress-indicator {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.progress-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #444;
opacity: 0.4;
transition: background 0.2s, opacity 0.2s;
}
.progress-dot.active {
background: #fff;
opacity: 1;
}
.quick-pick-btn {
min-width: 80px;
min-height: 44px;
font-size: 1.1rem;
border-radius: 8px;
background: #333;
color: #fff;
border: none;
cursor: pointer;
margin-bottom: 8px;
transition: background 0.2s;
}
.quick-pick-btn:active, .quick-pick-btn:focus {
background: #555;
outline: none;
}
.arrow-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 48px;
width: 100%;
}
.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);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, color 0.2s;
}
.arrow-btn:active, .arrow-btn:focus {
background: #444;
color: #fff;
outline: none;
}
.clear-input-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 24px;
color: #aaa;
padding: 0;
z-index: 2;
}
.clear-input-btn:active, .clear-input-btn:focus {
color: #fff;
outline: none;
}
.game-type-selection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
width: 100%;
margin: 16px 0;
}
.game-type-btn {
background: #2a2a2a;
border: 2px solid #333;
color: #fff;
font-size: 1.4rem;
font-weight: 600;
padding: 24px;
border-radius: 8px;
cursor: pointer;
text-align: center;
transition: background 0.2s, border-color 0.2s;
}
.game-type-btn:hover {
background: #333;
border-color: #555;
}
.game-type-btn.selected {
background: #4a4a4a;
border-color: #777;
}
.race-to-selection {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 12px;
width: 100%;
margin: 16px 0;
}
.race-to-btn {
background: #2a2a2a;
border: 2px solid #333;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
padding: 16px 8px;
border-radius: 8px;
cursor: pointer;
text-align: center;
transition: background 0.2s, border-color 0.2s;
min-height: 70px;
}
.race-to-btn:hover {
background: #333;
border-color: #555;
}
.race-to-btn.selected {
background: #4a4a4a;
border-color: #777;
}
.custom-race-to {
display: flex;
gap: 12px;
margin-top: 24px;
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%;
}
@@ -1,70 +0,0 @@
.modalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modalContent {
background: #2c2c2c;
padding: 24px;
border-radius: 12px;
width: 90%;
max-width: 400px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
}
.modalHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modalHeader h3 {
margin: 0;
font-size: 1.5rem;
color: #fff;
}
.closeButton {
background: none;
border: none;
font-size: 2rem;
color: #aaa;
cursor: pointer;
padding: 0;
line-height: 1;
}
.playerList {
max-height: 60vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.playerItem {
background: #444;
color: #fff;
border: none;
padding: 16px;
border-radius: 8px;
text-align: left;
font-size: 1.2rem;
cursor: pointer;
transition: background-color 0.2s;
}
.playerItem:hover {
background: #555;
}
@@ -0,0 +1,46 @@
import { h } from 'preact';
import { Screen } from '@lib/ui/Layout';
import GameDetail from '@lib/features/game-detail/GameDetail';
import type { Game, EndlosGame } from '@lib/domain/types';
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>Spiel nicht gefunden</div>
</Screen>
);
}
return (
<Screen>
<GameDetail
game={game}
onUpdateScore={onUpdateScore}
onFinishGame={onFinishGame}
onUpdateGame={onUpdateGame}
onUndo={onUndo}
onForfeit={onForfeit}
onBack={onBack}
/>
</Screen>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { h } from 'preact';
import { Button } from '@lib/ui/Button';
import { Screen } from '@lib/ui/Layout';
import GameList from '@lib/features/game-list/GameList';
import type { Game, GameFilter } from '@lib/domain/types';
import styles from '@lib/features/game-list/GameList.module.css';
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>
<div className={styles.newGameWrapper}>
<Button
variant="primary"
size="large"
onClick={onShowNewGame}
aria-label="Neues Spiel starten"
>
+ Neues Spiel
</Button>
</div>
<GameList
games={games}
filter={filter}
onShowGameDetail={onShowGameDetail}
onDeleteGame={onDeleteGame}
setFilter={onFilterChange}
/>
</Screen>
);
}
+247
View File
@@ -0,0 +1,247 @@
import { h } from 'preact';
import type { ComponentProps } from 'preact';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Screen } from '@lib/ui/Layout';
import {
PlayerNameStep,
GameTypeStep,
BreakRuleStep,
BreakOrderStep,
RaceToStep,
} from '@lib/features/new-game';
import type { NewGameStep, NewGameData, GameType } from '@lib/domain/types';
import { ERROR_MESSAGES, GAME_TYPES, RACE_TO_DEFAULT } from '@lib/domain/constants';
import newGameStyles from '@lib/features/new-game/NewGame.module.css';
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;
}
type PlayerStepKey = 'player1' | 'player2' | 'player3';
type PlayerStepConfig = Omit<ComponentProps<typeof PlayerNameStep>, 'playerNameHistory'>;
type PlayerStepConfigMap = Record<PlayerStepKey, PlayerStepConfig>;
export default function NewGameScreen({
step,
data,
playerHistory,
onStepChange,
onDataChange,
onCreateGame,
onCancel,
onShowValidation,
}: NewGameScreenProps) {
const [temporaryPlayerNames, setTemporaryPlayerNames] = useState<string[]>([]);
useEffect(() => {
// Reset wizard-local names when a fresh wizard starts at player 1.
if (
step === 'player1' &&
!data.player1 &&
!data.player2 &&
!data.player3
) {
setTemporaryPlayerNames([]);
}
}, [step, data.player1, data.player2, data.player3]);
const playerNameOptions = useMemo(
() => Array.from(new Set([...temporaryPlayerNames, ...playerHistory])),
[temporaryPlayerNames, playerHistory],
);
const addTemporaryPlayerName = (name: string) => {
setTemporaryPlayerNames((prev) =>
prev.includes(name) ? prev : [name, ...prev],
);
};
const handleGameTypeNext = (type: string) => {
const selectedType = type as GameType;
const match = GAME_TYPES.find((item) => item.value === selectedType);
const defaultRace = match ? String(match.defaultRaceTo) : String(RACE_TO_DEFAULT);
onDataChange({
gameType: selectedType,
raceTo: defaultRace,
});
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 | number) => {
// Convert to string, handling Infinity case explicitly
const raceToStr = raceTo === Infinity ? 'Infinity' : String(raceTo);
// After race to, go to break rule selection
onDataChange({ raceTo: raceToStr });
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();
}
};
const playerStepConfig: PlayerStepConfigMap = {
player1: {
stepNumber: 1,
stepClassName: newGameStyles['step-player1'],
title: 'Name Spieler 1',
label: 'Spieler 1',
placeholder: 'Name Spieler 1',
inputId: 'player1-input',
playerInputClassName: newGameStyles['player1-input'],
initialValue: data.player1,
onNext: (name: string) => {
onDataChange({ player1: name });
onStepChange('player2');
},
onCancel,
required: true,
requiredErrorMessage: ERROR_MESSAGES.PLAYER1_REQUIRED,
enterKeyHint: 'next',
showMorePlayersModal: true,
},
player2: {
stepNumber: 2,
stepClassName: newGameStyles['step-player2'],
title: 'Name Spieler 2',
label: 'Spieler 2',
placeholder: 'Name Spieler 2',
inputId: 'player2-input',
playerInputClassName: newGameStyles['player2-input'],
initialValue: data.player2,
onNext: (name: string) => {
onDataChange({ player2: name });
onStepChange('player3');
},
onCancel: handleStepBack,
required: true,
requiredErrorMessage: ERROR_MESSAGES.PLAYER2_REQUIRED,
enterKeyHint: 'next',
showMorePlayersModal: true,
prefillNameEntry: false,
onCreateTemporaryName: addTemporaryPlayerName,
},
player3: {
stepNumber: 3,
stepClassName: newGameStyles['step-player3'],
title: 'Name Spieler 3 (optional)',
label: 'Spieler 3 (optional)',
placeholder: 'Name Spieler 3 (optional)',
inputId: 'player3-input',
playerInputClassName: newGameStyles['player3-input'],
initialValue: data.player3,
onNext: (name: string) => {
onDataChange({ player3: name });
onStepChange('gameType');
},
onCancel: handleStepBack,
required: false,
enterKeyHint: 'done',
showSkipButton: true,
skipLabel: 'Überspringen',
showMorePlayersModal: true,
prefillNameEntry: false,
onCreateTemporaryName: addTemporaryPlayerName,
},
};
return (
<Screen>
{(step === 'player1' || step === 'player2' || step === 'player3') && (
<PlayerNameStep
key={step}
{...playerStepConfig[step]}
playerNameHistory={playerNameOptions}
data={data}
currentStep={step}
/>
)}
{step === 'gameType' && (
<GameTypeStep
onNext={handleGameTypeNext}
onCancel={handleStepBack}
initialValue={data.gameType}
data={data}
/>
)}
{step === 'raceTo' && (
<RaceToStep
onNext={handleRaceToNext}
onCancel={handleStepBack}
initialValue={data.raceTo}
gameType={data.gameType}
data={data}
/>
)}
{step === 'breakRule' && (
<BreakRuleStep
onNext={handleBreakRuleNext}
onCancel={handleStepBack}
initialValue={(data.breakRule as any) || (typeof window !== 'undefined' ? (localStorage.getItem('lastBreakRule') as any) : 'winnerbreak') || 'winnerbreak'}
data={data}
/>
)}
{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}
data={data}
/>
)}
</Screen>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { h } from 'preact';
import type { ButtonProps } from '@lib/ui/types';
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>
);
}
+33
View File
@@ -0,0 +1,33 @@
# Data Layer (`@lib/data`)
Responsible for persistence and data access abstractions. All I/O lives here.
## Modules
- `gameService.ts`
- High-level repository for CRUD operations on games.
- Bridges domain rules with `IndexedDBService`.
- Provides helpers: `loadGames`, `saveGame`, `createGame`, `updateGameScore`, `isGameCompleted`, `getGameWinner`, etc.
- `indexedDBService.ts`
- Low-level IndexedDB wrapper with schema management and convenience indices.
- Exposes granular operations (`loadGame`, `deleteGame`, `getGamesByFilter`, `updatePlayerStats`, …).
- `testing/testIndexedDB.ts`
- Browser-side harness to validate IndexedDB flows manually.
## Guidance
- Prefer calling `GameService` from UI/state hooks.
It encapsulates migration logic (localStorage fallback) and player stats updates.
- Keep new persistence concerns behind small classes or factory functions under `@lib/data`.
- When a data function starts bleeding UI concerns, move that logic upward into `@lib/state`.
## Example
```ts
import { GameService } from '@lib/data';
const games = await GameService.loadGames();
await GameService.saveGame(updatedGame);
```
+330
View File
@@ -0,0 +1,330 @@
import type {
Game,
GameType,
StandardGame,
EndlosGame,
NewGameData,
BreakRule,
} from '@lib/domain/types';
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');
}
// Handle "endlos" (Infinity) case - raceTo is stored as string but can be "Infinity"
let raceTo: number;
if (gameData.raceTo === 'Infinity' || gameData.raceTo === 'endlos' || String(gameData.raceTo).toLowerCase() === 'infinity') {
raceTo = Infinity;
} else {
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 };
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './gameService';
export * from './indexedDBService';
+353
View File
@@ -0,0 +1,353 @@
import type { Game } from '@lib/domain/types';
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,
};
}
}
+140
View File
@@ -0,0 +1,140 @@
import { IndexedDBService } from '@lib/data/indexedDBService';
import { GameService } from '@lib/data/gameService';
import type { NewGameData } from '@lib/domain/types';
/**
* 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;
}
+64
View File
@@ -0,0 +1,64 @@
# BSC Score Modular Architecture
This document captures the reusable module layout introduced in the refactor.
Everything under `src/lib` is now consumable from other React/Preact hosts without depending on the Astro shell that ships in this repository.
## Module Overview
- `@lib/domain` – **Pure domain logic** (types, constants, validation helpers, formatters).
No framework dependencies, no browser APIs. Safe to re-use from Node or tests.
- `@lib/data` – **Persistence services** (IndexedDB access, repository abstractions, test harness).
Wraps browser APIs and isolates side-effects away from UI/state.
- `@lib/state` – **Composable hooks** that orchestrate domain + data layers.
Exported hooks expose serialisable state and imperative actions.
- `@lib/ui` – **Stateless presentational primitives** (buttons, cards, layout shell, generic modals).
Ship with co-located CSS modules and type-safe props.
- `@lib/features/*` – **Feature bundles** that compose UI + state to deliver end-user flows.
Current bundles:
- `game-list` (list + filter experience)
- `game-detail` (scoreboard with break tracking)
- `game-lifecycle` (completion modal + rematch workflow)
- `new-game` (wizard steps and player pickers)
## Import Surfaces
All entry points are re-exported via `@lib/index.ts`, so consumers can either:
```ts
import { GameService, useGameState, GameList } from '@lib';
```
or pick a scoped module:
```ts
import { GameService } from '@lib/data';
import { GameList } from '@lib/features/game-list';
```
## Cross-Cutting Rules
- **Domain first**: New feature logic should be expressed with domain types and helpers before touching hooks/UI.
- **One-way dependencies**:
```
domain → data → state → features → app shell
```
Lower layers must not import from higher ones.
- **CSS modules stay co-located** with the component they style. Consumers receive the compiled class names via the exported component props.
- **Documentation lives next to code**: every module has a `README.md` describing intent, public API, and integration notes.
## Migration Notes
- Legacy `src/components` now only hosts the Astro shell (`App.tsx`, `BscScoreApp.astro`, etc.).
All reusable React pieces were migrated under `src/lib`.
- Path aliases:
- `@/*` → `src/*`
- `@lib/*` → `src/lib/*`
Update your editor/tsconfig if you embed these modules elsewhere.
- Tests should import from `@lib` to avoid depending on Astro-specific wiring.
## Next Steps
- Promote more feature-level Storybook / Playwright fixtures into `src/lib/docs`.
- When adding new modules, extend `src/lib/index.ts` and document usage in the corresponding folder.
+27
View File
@@ -0,0 +1,27 @@
# Domain Layer (`@lib/domain`)
Pure domain model primitives for BSC Score. Everything here is framework-agnostic and free of side effects.
## Exports
- `types.ts` – canonical TypeScript shapes (`Game`, `StandardGame`, `NewGameData`, etc.).
- `constants.ts` – configuration, validation copy, UI sizing tokens.
- `validation.ts` – safe helpers for validating/sanitising player & game inputs.
- `gameUtils.ts` – derived state utilities (duration, winner detection, type guards).
## Usage Guidelines
- Treat the domain layer as the single source of truth for typings across the app (UI, services, tests).
- Keep functions pure and deterministic; no DOM, storage, or logging side effects beyond debug `console` statements.
- When extending the data model, update the corresponding domain `README.md` section and propagate new types through `@lib/domain/index.ts`.
## Example
```ts
import { Game, validateGameData, GAME_TYPES } from '@lib/domain';
const result = validateGameData(dataFromForm); // -> { isValid, errors }
const isEndlos = GAME_TYPES.some((type) => type.value === '8-Ball');
```
+133
View File
@@ -0,0 +1,133 @@
import type { GameType } from './types';
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: 7,
// 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
View File
@@ -0,0 +1,108 @@
import type { Game, StandardGame, EndlosGame } from './types';
/**
* 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
);
}
+5
View File
@@ -0,0 +1,5 @@
export * from './types';
export * from './constants';
export * from './validation';
export * from './gameUtils';
+74
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' | 'raceTo' | 'breakRule' | 'breakOrder' | null;
export type GameFilter = 'all' | 'active' | 'completed';
+99
View File
@@ -0,0 +1,99 @@
import { APP_CONFIG, VALIDATION_MESSAGES } from './constants';
import type { NewGameData } from './types';
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,
};
}
+30
View File
@@ -0,0 +1,30 @@
# Feature Bundles (`@lib/features`)
Feature directories compose domain, data, state, and UI primitives into end-user flows. Each folder exports React/Preact components that can be dropped into any host application.
## Available Features
- `game-list`
- `GameList` component that renders filters + game cards.
- `game-detail`
- `GameDetail` scoreboard with break tracking, undo triggers, and finish controls.
- `game-lifecycle`
- `GameCompletionModal` summarising winners + rematch CTA.
- `new-game`
- Wizard step components (`PlayerNameStep`, `BreakOrderStep`, etc.) and modal pickers.
## Usage Example
```tsx
import { GameList, GameCompletionModal } from '@lib/features/game-list';
// or, via umbrella export:
import { GameDetail, GameCompletionModal } from '@lib';
```
## Conventions
- Feature components accept plain props (typically typed with `@lib/domain` types) and delegate callbacks to the consumer.
- State management lives in `@lib/state`. Features should remain stateless except for local UI state (e.g. input fields).
- Keep CSS modules inside the feature folder to avoid cross-feature leakage.
@@ -0,0 +1,376 @@
/* Scoreboard — primary screen, optimized for landscape tablets */
.gameDetail {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: var(--space-md);
padding-bottom: calc(var(--space-md) + var(--safe-area-bottom));
user-select: none;
-webkit-user-select: none;
}
.gameHeader {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
margin-bottom: var(--space-md);
gap: var(--space-md);
}
.gameHeaderInner {
display: flex;
align-items: center;
gap: var(--space-md);
min-width: 0;
}
.bscMark {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--color-primary) 0%, #e65100 100%);
color: #1a1a1a;
font-size: var(--font-size-xs);
font-weight: 900;
letter-spacing: 0.08em;
box-shadow: 0 0 12px rgba(255, 152, 0, 0.35);
}
.gameTitle {
font-size: clamp(1rem, 2.5vh, 1.5rem);
color: var(--color-text);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.statusBadge {
flex-shrink: 0;
padding: var(--space-xs) var(--space-md);
background: var(--color-primary);
color: #1a1a1a;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.scoresContainer {
display: flex;
flex: 1;
justify-content: stretch;
align-items: stretch;
gap: var(--space-md);
min-height: 0;
}
.playerScore {
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
padding: var(--space-lg) var(--space-md);
border-radius: var(--radius-xl);
position: relative;
box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.08);
min-width: 0;
min-height: 0;
border: 3px solid transparent;
transition: border-color var(--transition-base), transform var(--transition-fast);
overflow: hidden;
cursor: pointer;
touch-action: manipulation;
}
.player1 {
background: linear-gradient(180deg, #3a5a3d 0%, var(--color-player-1) 100%);
}
.player2 {
background: linear-gradient(180deg, #354d6a 0%, var(--color-player-2) 100%);
}
.player3 {
background: linear-gradient(180deg, #444 0%, var(--color-player-3) 100%);
}
.playerScore.completed {
opacity: 0.85;
cursor: default;
}
.playerScore:focus-visible {
outline: 3px solid #fff;
outline-offset: 4px;
}
.playerScore:active:not([aria-disabled="true"]) {
transform: scale(0.99);
}
.playerScore.winner {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary), 0 0 28px rgba(255, 152, 0, 0.45), var(--shadow-lg);
}
.playerScore.winner .playerName {
color: var(--color-primary);
}
.playerNameRow {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
width: 100%;
margin-bottom: var(--space-sm);
flex-shrink: 0;
}
.playerName {
font-size: clamp(1.1rem, 3vh, 1.75rem);
font-weight: 700;
color: #fff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.breakIndicator {
display: inline-block;
width: clamp(14px, 2.5vh, 22px);
height: clamp(14px, 2.5vh, 22px);
border-radius: 50%;
background: #fff;
flex-shrink: 0;
box-shadow: 0 0 6px rgba(255, 255, 255, 0.6);
animation: breakPulse 2s ease-in-out infinite;
}
@keyframes breakPulse {
0%, 100% {
opacity: 1;
box-shadow: 0 0 6px rgba(255, 255, 255, 0.8);
}
50% {
opacity: 0.65;
box-shadow: 0 0 14px rgba(255, 255, 255, 0.95), 0 0 20px rgba(255, 152, 0, 0.4);
}
}
.winnerBadge {
font-size: clamp(1rem, 2.5vh, 1.5rem);
color: var(--color-primary);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.progressBar {
width: 80%;
height: 6px;
background: rgba(255, 255, 255, 0.25);
border-radius: var(--radius-sm);
margin-bottom: var(--space-sm);
overflow: hidden;
flex-shrink: 0;
}
.progressFill {
height: 100%;
background: rgba(255, 255, 255, 0.9);
border-radius: var(--radius-sm);
transition: width var(--transition-slow);
}
.score {
font-size: clamp(4.5rem, 30vh, 14rem);
font-weight: 900;
line-height: 1;
color: #fff;
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
text-align: center;
display: flex;
align-items: center;
justify-content: center;
flex: 1 1 0;
width: 100%;
min-height: 0;
border-radius: var(--radius-lg);
padding: var(--space-xs);
font-variant-numeric: tabular-nums;
position: relative;
pointer-events: none;
}
.score::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.35) 0%, transparent 70%);
opacity: 0;
transform: scale(0.5);
pointer-events: none;
}
.scoreTap::after {
animation: scoreRipple 0.35s ease-out forwards;
}
.scoreBump {
animation: scoreBump 0.25s ease-out;
}
@keyframes scoreRipple {
0% { opacity: 0.6; transform: scale(0.4); }
100% { opacity: 0; transform: scale(1.4); }
}
@keyframes scoreBump {
0% { transform: scale(1); }
40% { transform: scale(1.12); }
100% { transform: scale(1); }
}
.gameDetailControls {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--space-md);
margin-top: var(--space-lg);
padding-bottom: calc(var(--space-md) + var(--keyboard-offset));
width: 100%;
justify-content: center;
flex-shrink: 0;
}
.gameDetailControls > * {
flex: 1;
min-width: min(160px, 100%);
max-width: 280px;
}
.finishButton {
border: 2px solid var(--color-border) !important;
opacity: 0.85;
max-width: 220px;
}
/* Landscape tablet — maximize score visibility */
@media (orientation: landscape) and (min-width: 768px) {
.gameDetail {
padding: var(--space-lg);
}
.scoresContainer {
gap: var(--space-lg);
}
.playerScore {
padding: var(--space-xl) var(--space-lg);
}
}
/* Narrow portrait — stack players */
@media (max-width: 600px) {
.scoresContainer {
flex-direction: column;
}
.score {
font-size: clamp(3rem, 20vw, 6rem);
}
}
/* Landscape phone — keep scores readable, compact chrome */
@media (max-height: 500px) and (orientation: landscape) {
.gameDetail {
padding: var(--space-xs) var(--space-sm);
padding-bottom: calc(var(--space-xs) + var(--safe-area-bottom));
}
.gameHeader {
margin-bottom: var(--space-xs);
}
.bscMark {
width: 28px;
height: 28px;
font-size: 0.6rem;
}
.gameTitle {
font-size: clamp(0.85rem, 2.2vh, 1.1rem);
}
.scoresContainer {
flex-direction: row;
gap: var(--space-xs);
flex: 1 1 0;
min-height: 0;
}
.playerScore {
padding: var(--space-xs);
}
.playerNameRow {
margin-bottom: var(--space-xs);
}
.playerName {
font-size: clamp(0.75rem, 2.2vh, 1rem);
}
.progressBar {
height: 4px;
margin-bottom: var(--space-xs);
width: 90%;
}
.score {
font-size: clamp(2rem, min(36vh, 24vw), 4.5rem);
padding: 0;
flex: 1 1 0;
}
.gameDetailControls {
margin-top: var(--space-xs);
gap: var(--space-xs);
padding-bottom: calc(var(--space-xs) + var(--keyboard-offset));
}
.gameDetailControls > * {
min-width: 0;
max-width: none;
flex: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.breakIndicator {
animation: none;
}
.scoreTap::after,
.scoreBump {
animation: none;
}
}
+186
View File
@@ -0,0 +1,186 @@
import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import styles from './GameDetail.module.css';
import { Button } from '@lib/ui/Button';
import { getGameWinner, isStandardGame } from '@lib/domain/gameUtils';
import type { Game, EndlosGame } from '@lib/domain/types';
interface GameDetailProps {
game: Game | undefined;
onFinishGame: () => void;
onUpdateScore: (player: number, change: number) => void;
onUpdateGame?: (game: EndlosGame) => void;
onUndo?: () => void;
onForfeit?: () => void;
onBack: () => void;
}
interface AnimatedScoreProps {
value: number;
tapping: boolean;
}
function AnimatedScore({ value, tapping }: AnimatedScoreProps) {
const [bumping, setBumping] = useState(false);
const prevValue = useRef(value);
useEffect(() => {
if (prevValue.current !== value) {
setBumping(true);
const timer = window.setTimeout(() => setBumping(false), 250);
prevValue.current = value;
return () => window.clearTimeout(timer);
}
}, [value]);
const className = [
styles.score,
tapping ? styles.scoreTap : '',
bumping ? styles.scoreBump : '',
].filter(Boolean).join(' ');
return (
<span className={className} aria-hidden="true">
{value}
</span>
);
}
/**
* Game detail view for a single game.
*/
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUndo, onBack }: GameDetailProps) => {
const [tappingPlayer, setTappingPlayer] = useState<number | null>(null);
if (!game || !isStandardGame(game)) return null;
const standardGame = game;
const isCompleted = standardGame.status === 'completed';
const winner = getGameWinner(standardGame);
const playerNames = [standardGame.player1, standardGame.player2, standardGame.player3].filter(Boolean);
const scores = [standardGame.score1, standardGame.score2, standardGame.score3].filter((_, i) => playerNames[i]);
const order = standardGame.breakOrder;
const breakerIdx = standardGame.currentBreakerIdx;
const titleParts = [
standardGame.gameType,
standardGame.raceTo && standardGame.raceTo !== Infinity
? `Race to ${standardGame.raceTo}`
: standardGame.raceTo === Infinity
? 'Endlos'
: null,
].filter(Boolean);
return (
<div className={styles.gameDetail}>
<header className={styles.gameHeader}>
<div className={styles.gameHeaderInner}>
<span className={styles.bscMark} aria-hidden="true">BSC</span>
<div className={styles.gameTitle}>{titleParts.join(' · ')}</div>
</div>
{isCompleted && (
<span className={styles.statusBadge} aria-label="Spiel abgeschlossen">Abgeschlossen</span>
)}
</header>
<div className={styles.scoresContainer} aria-label="Punktestand">
{playerNames.map((name, idx) => {
const currentScore = scores[idx] ?? 0;
const playerName = name as string;
const progressPercentage = standardGame.raceTo && standardGame.raceTo !== Infinity
? Math.min((currentScore / standardGame.raceTo) * 100, 100)
: 0;
const isBreaker = order && typeof breakerIdx === 'number' && order[breakerIdx] === idx + 1;
const isWinner = isCompleted && winner === playerName;
const playerClass = [
styles.playerScore,
styles[`player${idx + 1}` as keyof typeof styles],
isWinner ? styles.winner : '',
isCompleted ? styles.completed : '',
].filter(Boolean).join(' ');
const handleScore = () => {
if (isCompleted) return;
setTappingPlayer(idx);
window.setTimeout(() => setTappingPlayer((current) => (current === idx ? null : current)), 350);
onUpdateScore(idx + 1, 1);
};
return (
<div
className={playerClass}
key={playerName + idx}
id={`score${idx + 1}`}
onClick={handleScore}
onKeyDown={(e) => {
if (!isCompleted && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleScore();
}
}}
role="button"
tabIndex={isCompleted ? -1 : 0}
aria-label={`Punkt für ${playerName}. Aktueller Stand: ${currentScore}`}
aria-disabled={isCompleted}
>
<div className={styles.playerNameRow}>
<span className={styles.playerName}>{playerName}</span>
{isBreaker && !isCompleted && (
<span
className={styles.breakIndicator}
title="Break"
aria-label={`${playerName} hat den Break`}
/>
)}
{isWinner && (
<span className={styles.winnerBadge} aria-label="Gewinner">★</span>
)}
</div>
{standardGame.raceTo && standardGame.raceTo !== Infinity && (
<div className={styles.progressBar} aria-hidden="true">
<div
className={styles.progressFill}
style={{ width: `${progressPercentage}%` }}
/>
</div>
)}
<AnimatedScore value={currentScore} tapping={tappingPlayer === idx} />
</div>
);
})}
</div>
<div className={styles.gameDetailControls}>
<Button variant="secondary" size="medium" onClick={onBack} aria-label="Zurück zur Liste">
Zurück
</Button>
{onUndo && (
<Button
variant="secondary"
size="medium"
onClick={onUndo}
aria-label="Letzten Punkt rückgängig machen"
>
↶ Rückgängig
</Button>
)}
<Button
variant="secondary"
size="medium"
disabled={isCompleted}
onClick={onFinishGame}
aria-label={isCompleted ? 'Spiel bereits abgeschlossen' : 'Spiel beenden'}
className={styles.finishButton}
>
{isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}
</Button>
</div>
</div>
);
};
export default GameDetail;
+34
View File
@@ -0,0 +1,34 @@
# Game Detail (`@lib/features/game-detail`)
`GameDetail` shows a single game's state, including live score controls and break indicators.
## Props
- `game: Game`
- `onUpdateScore(playerIndex: number, delta: number): void`
- `onFinishGame(): void`
- `onUpdateGame?(game: Game): void`
- `onUndo?(): void`
- `onForfeit?(): void`
- `onBack(): void`
## Highlights
- Handles both standard and endlos game modes.
- Displays current breaker marker based on `breakOrder` / `currentBreakerIdx`.
- Uses accessible button semantics so scores can be increased via keyboard.
## Example
```tsx
import { GameDetail } from '@lib/features/game-detail';
<GameDetail
game={selectedGame}
onUpdateScore={(player, change) => GameService.saveGame(...)}
onFinishGame={endGame}
onBack={showGameList}
/>;
```
+2
View File
@@ -0,0 +1,2 @@
export { default as GameDetail } from './GameDetail';
@@ -0,0 +1,152 @@
.modalWrapper {
position: relative;
overflow: hidden;
}
.confetti {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 1;
}
.confettiPiece {
position: absolute;
top: -12px;
width: 8px;
height: 8px;
border-radius: 2px;
opacity: 0;
animation: confettiFall 1.5s ease-out forwards;
animation-delay: calc(var(--i, 0) * 0.06s);
left: calc(8% + var(--i, 0) * 7%);
}
.confettiPiece:nth-child(3n) {
background: var(--color-primary);
}
.confettiPiece:nth-child(3n + 1) {
background: #ffc107;
}
.confettiPiece:nth-child(3n + 2) {
background: #ffeb3b;
}
@keyframes confettiFall {
0% {
opacity: 1;
transform: translateY(0) rotate(0deg) scale(1);
}
100% {
opacity: 0;
transform: translateY(280px) rotate(540deg) scale(0.6);
}
}
.finalScores {
margin: var(--space-md) 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
position: relative;
z-index: 2;
}
.finalScore {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md) var(--space-lg);
background: var(--color-background);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
min-height: var(--touch-target-comfortable);
}
.finalScoreWinner {
border-color: var(--color-primary);
background: var(--color-primary-light);
box-shadow: 0 0 0 2px var(--color-primary);
}
.finalScore .playerName {
font-size: var(--font-size-lg);
font-weight: 700;
color: var(--color-text);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.finalScore .score {
font-size: clamp(1.75rem, 5vw, 2.5rem);
font-weight: 800;
color: var(--color-primary);
font-variant-numeric: tabular-nums;
}
.winnerAnnouncement {
text-align: center;
margin-top: var(--space-lg);
padding: var(--space-xl) var(--space-lg);
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-hover) 100%);
border-radius: var(--radius-lg);
color: #1a1a1a;
font-weight: 700;
box-shadow: var(--shadow-md), 0 0 24px rgba(255, 152, 0, 0.35);
position: relative;
z-index: 2;
animation: winnerGlow 2s ease-in-out infinite;
}
@keyframes winnerGlow {
0%, 100% {
box-shadow: var(--shadow-md), 0 0 20px rgba(255, 152, 0, 0.3);
}
50% {
box-shadow: var(--shadow-md), 0 0 32px rgba(255, 152, 0, 0.55);
}
}
.winnerAnnouncement h3 {
margin: 0;
font-size: clamp(1.1rem, 3vw, 1.6rem);
color: #1a1a1a;
text-align: center;
font-weight: 800;
line-height: 1.3;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2);
}
.modalActions {
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-top: var(--space-lg);
position: relative;
z-index: 2;
}
@media (min-width: 480px) {
.modalActions {
flex-direction: row;
flex-wrap: wrap;
}
.modalActions > * {
flex: 1;
min-width: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.confettiPiece {
display: none;
}
.winnerAnnouncement {
animation: none;
}
}
@@ -0,0 +1,90 @@
import { h } from 'preact';
import modalStyles from '@lib/ui/Modal.module.css';
import styles from './GameCompletionModal.module.css';
import { Button } from '@lib/ui/Button';
import type { Game } from '@lib/domain/types';
import { getGamePlayers, isEndlosGame } from '@lib/domain/gameUtils';
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 players = getGamePlayers(game);
const playerNames = players.map((player) => player.name);
const scores = players.map((player) => player.score);
const maxScore = Math.max(...scores);
const winners = playerNames.filter((name, idx) => (scores[idx] ?? 0) === maxScore);
let winnerText: string;
if (isEndlosGame(game) && game.forfeitedBy) {
winnerText = `${game.winner} hat gewonnen, da ${game.forfeitedBy} aufgegeben hat.`;
} else {
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']} ${styles.modalWrapper}`}>
<div className={styles.confetti} aria-hidden="true">
{Array.from({ length: 12 }, (_, i) => (
<span key={i} className={styles.confettiPiece} style={{ '--i': i } as Record<string, number>} />
))}
</div>
<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" type="button">×</button>
</div>
<div className={modalStyles['modal-body']}>
<div className={styles.finalScores}>
{playerNames.map((name, idx) => {
const isWinner = !(isEndlosGame(game) && game.forfeitedBy) && winners.includes(name) && winners.length === 1;
return (
<div
className={`${styles.finalScore} ${isWinner ? styles.finalScoreWinner : ''}`}
key={`${name}-${idx}`}
>
<span className={styles.playerName}>{name}</span>
<span className={styles.score}>{scores[idx] ?? 0}</span>
</div>
);
})}
</div>
<div className={styles.winnerAnnouncement}>
<h3>{winnerText}</h3>
</div>
</div>
<div className={styles.modalActions}>
<Button variant="primary" size="large" onClick={onRematch} aria-label="Rematch starten">
Rematch
</Button>
<Button variant="secondary" size="large" onClick={onConfirm} aria-label="Spiel bestätigen und abschließen">
Bestätigen
</Button>
<Button variant="secondary" size="large" onClick={onClose} aria-label="Abbrechen">
Abbrechen
</Button>
</div>
</div>
</div>
);
};
export default GameCompletionModal;
+30
View File
@@ -0,0 +1,30 @@
# Game Lifecycle (`@lib/features/game-lifecycle`)
Utility components that react to lifecycle transitions in a game session.
## GameCompletionModal
- Props:
- `open: boolean`
- `game: Game | null`
- `onConfirm(): void`
- `onClose(): void`
- `onRematch(): void`
- Renders final scores, winner messaging, and rematch CTA.
- Reuses `@lib/ui/Modal.module.css` for a consistent look-and-feel.
## Example
```tsx
import { GameCompletionModal } from '@lib/features/game-lifecycle';
<GameCompletionModal
open={state.open}
game={state.game}
onConfirm={finalise}
onRematch={startRematch}
onClose={closeModal}
/>;
```
+2
View File
@@ -0,0 +1,2 @@
export { default as GameCompletionModal } from './GameCompletionModal';
@@ -0,0 +1,234 @@
.gameList {
width: 100%;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.newGameWrapper {
flex-shrink: 0;
margin-bottom: var(--space-lg);
}
.filterButtons {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
flex-shrink: 0;
}
.filterButtons > * {
flex: 1 1 auto;
min-width: min(160px, calc(50% - var(--space-sm)));
min-height: var(--touch-target-large);
}
.gamesContainer {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-md);
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
padding-bottom: calc(var(--space-md) + var(--keyboard-offset));
-webkit-overflow-scrolling: touch;
}
.gameItem {
display: grid;
grid-template-columns: 1fr auto;
align-items: stretch;
gap: var(--space-md);
overflow: hidden;
width: 100%;
transition: transform var(--transition-fast), box-shadow var(--transition-base);
}
.gameItem:active {
transform: scale(0.99);
}
.gameItem.active {
border-left: 5px solid var(--color-primary);
box-shadow: var(--shadow-md);
}
.gameItem.completed {
border-left: 5px solid var(--color-text-muted);
opacity: 0.88;
}
.gameInfo {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-lg);
cursor: pointer;
min-width: 0;
touch-action: manipulation;
}
.gameInfo:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.gameMeta {
display: flex;
align-items: center;
gap: var(--space-md);
flex-wrap: wrap;
}
.statusChip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
min-height: var(--touch-target-min);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
flex-shrink: 0;
}
.liveDot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff;
animation: livePulse 1.5s ease-in-out infinite;
}
@keyframes livePulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.85); }
}
.statusActive {
background: var(--color-primary);
color: #1a1a1a;
}
.statusCompleted {
background: var(--color-secondary);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.gameType {
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.playerNames {
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 600;
line-height: 1.3;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.gameScores {
font-size: var(--font-size-xxl);
font-weight: 800;
color: var(--color-primary);
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
}
.deleteWrapper {
display: flex;
align-items: center;
padding: var(--space-md);
flex-shrink: 0;
}
.deleteButton {
min-width: 100px !important;
width: auto !important;
}
.emptyState {
text-align: center;
padding: var(--space-xxl) var(--space-lg);
color: var(--color-text-muted);
font-size: var(--font-size-lg);
background: linear-gradient(180deg, #2a2a2a 0%, var(--color-surface) 100%);
border-radius: var(--radius-lg);
border: 2px dashed var(--color-border);
width: 100%;
}
.emptyBall {
font-size: 3rem;
line-height: 1;
margin-bottom: var(--space-md);
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
}
.emptyHint {
margin-top: var(--space-sm);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
@media (min-width: 768px) {
.filterButtons {
gap: var(--space-md);
}
.gameInfo {
padding: var(--space-xl);
gap: var(--space-md);
}
.playerNames {
font-size: var(--font-size-xl);
}
.gameScores {
font-size: var(--font-size-xxxl);
}
.deleteButton {
min-width: 120px !important;
}
}
@media (max-width: 600px) {
.gameItem {
grid-template-columns: 1fr;
width: 100%;
}
.filterButtons > * {
min-width: calc(33% - var(--space-sm));
}
.deleteWrapper {
padding: 0 var(--space-lg) var(--space-lg);
}
.deleteButton {
width: 100% !important;
}
}
@media (prefers-reduced-motion: reduce) {
.liveDot {
animation: none;
}
.gameItem:active {
transform: none;
}
}
+135
View File
@@ -0,0 +1,135 @@
import { h } from 'preact';
import { Card } from '@lib/ui/Card';
import { Button } from '@lib/ui/Button';
import styles from './GameList.module.css';
import type { Game, GameFilter, StandardGame } from '@lib/domain/types';
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 => {
const g = game as StandardGame;
return g.player3
? `${g.player1} vs ${g.player2} vs ${g.player3}`
: `${g.player1} vs ${g.player2}`;
};
const getScores = (game: Game): string => {
const g = game as StandardGame;
return g.player3
? `${g.score1} – ${g.score2} – ${g.score3}`
: `${g.score1} – ${g.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.gameList}>
<div className={styles.filterButtons} role="group" aria-label="Spiele filtern">
{filterButtons.map(({ key, label, ariaLabel }) => (
<Button
key={key}
variant={filter === key ? 'primary' : 'secondary'}
size="medium"
onClick={() => setFilter(key)}
aria-label={ariaLabel}
aria-pressed={filter === key}
>
{label}
</Button>
))}
</div>
<div className={styles.gamesContainer}>
{filteredGames.length === 0 ? (
<div className={styles.emptyState} role="status">
<div className={styles.emptyBall} aria-hidden="true">🎱</div>
<p>Keine Spiele vorhanden</p>
<p className={styles.emptyHint}>Tippe oben auf „+ Neues Spiel"</p>
</div>
) : (
filteredGames.map(game => {
const playerNames = getPlayerNames(game);
const scores = getScores(game);
const isActive = game.status === 'active';
return (
<Card
key={game.id}
variant="elevated"
padding="none"
className={`${styles.gameItem} ${isActive ? styles.active : styles.completed}`}
>
<div
className={styles.gameInfo}
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.gameMeta}>
<span className={`${styles.statusChip} ${isActive ? styles.statusActive : styles.statusCompleted}`}>
{isActive && <span className={styles.liveDot} aria-hidden="true" />}
{isActive ? 'Live' : 'Fertig'}
</span>
<div className={styles.gameType}>
{game.gameType}{game.raceTo && game.raceTo !== Infinity ? ` · ${game.raceTo}` : game.raceTo === Infinity ? ' · Endlos' : ''}
</div>
</div>
<div className={styles.playerNames}>{playerNames}</div>
<div className={styles.gameScores}>{scores}</div>
<div id={`game-${game.id}-description`} className="sr-only">
{game.gameType} Spiel zwischen {playerNames} mit dem Stand {scores}.
{isActive ? 'Aktiv' : 'Abgeschlossen'}
</div>
</div>
<div className={styles.deleteWrapper}>
<Button
variant="danger"
size="medium"
onClick={() => onDeleteGame(game.id)}
aria-label={`Spiel löschen: ${playerNames}`}
className={styles.deleteButton}
>
Löschen
</Button>
</div>
</Card>
);
})
)}
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
# Game List (`@lib/features/game-list`)
Single component `GameList` renders the scoreboard overview with filter tabs.
## Props
- `games: Game[]`
- `filter: GameFilter`
- `setFilter(filter: GameFilter): void`
- `onShowGameDetail(gameId: number): void`
- `onDeleteGame(gameId: number): void`
## Behaviour
- Sorts games by `createdAt` (desc) and filters according to `filter`.
- Derives player names/scores for `StandardGame`.
- Uses `@lib/ui` primitives (`Button`, `Card`) for visuals.
## Example
```tsx
import { GameList } from '@lib/features/game-list';
<GameList
games={games}
filter={filter}
setFilter={setFilter}
onShowGameDetail={showDetail}
onDeleteGame={openDeleteModal}
/>;
```
+2
View File
@@ -0,0 +1,2 @@
export { default as GameList } from './GameList';
@@ -0,0 +1,742 @@
/* NewGame-specific styles only. Shared utility classes are now in global CSS. */
.screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
min-height: var(--app-height);
display: none;
opacity: 0;
transform: translateX(100%);
transition: transform var(--transition-slow), opacity var(--transition-slow);
}
.screen-content {
display: flex;
flex-direction: column;
height: 100%;
padding: var(--space-lg);
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
min-height: 0;
}
.screen-title {
font-size: clamp(1.25rem, 3vh, 1.5rem);
font-weight: 700;
color: var(--color-text);
margin-bottom: clamp(0.5rem, 2vh, 2rem);
letter-spacing: 0.5px;
text-align: center;
}
.player-inputs {
display: flex;
flex-direction: column;
gap: var(--space-lg);
width: 100%;
margin-bottom: var(--space-xl);
flex-shrink: 1;
min-height: 0;
}
.player-input {
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;
flex-shrink: 1;
min-height: 0;
overflow: hidden;
}
.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: clamp(0.5rem, 2vh, 1rem);
color: var(--color-text);
font-size: clamp(1rem, 2.5vh, 1.125rem);
font-weight: 600;
}
.name-input-container {
display: flex;
gap: var(--space-md);
position: relative;
}
.name-input {
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: var(--space-xl);
}
.setting-group {
margin-bottom: var(--space-lg);
}
.setting-group label {
display: block;
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: 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: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
.validation-error {
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;
}
/* NewGame-specific styles only. Shared utility classes are now in global CSS. */
.wizardShell {
flex: 1;
display: flex;
align-items: stretch;
justify-content: center;
width: 100%;
min-height: 0;
max-height: 100%;
overflow: hidden;
padding: var(--space-sm) 0;
}
.new-game-form {
width: min(94vw, 720px);
max-height: 100%;
margin: 0 auto;
background: linear-gradient(180deg, #2a2a2a 0%, var(--color-surface) 100%);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.06);
padding: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
flex-shrink: 1;
min-height: 0;
overflow: hidden;
animation: wizardStepIn var(--transition-slow) ease-out;
}
/* Per-step wizard backgrounds — visually distinct per step */
.new-game-form.step-player1 {
background: linear-gradient(180deg, #3a5a3d 0%, #2e4631 100%);
border-color: rgba(255, 255, 255, 0.12);
}
.new-game-form.step-player2 {
background: linear-gradient(180deg, #354d6a 0%, #2a3b52 100%);
border-color: rgba(255, 255, 255, 0.12);
}
.new-game-form.step-player3 {
background: linear-gradient(180deg, #444 0%, #333 100%);
border-color: rgba(255, 255, 255, 0.12);
}
.new-game-form.step-gameType {
background: linear-gradient(180deg, #5a2323 0%, #4a1c1c 100%);
border-color: rgba(255, 255, 255, 0.12);
}
.new-game-form.step-raceTo {
background: linear-gradient(180deg, #3d245e 0%, #2d1b4e 100%);
border-color: rgba(255, 255, 255, 0.12);
}
.new-game-form.step-breakRule {
background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%);
border-color: rgba(255, 255, 255, 0.12);
}
.new-game-form.step-breakOrder {
background: linear-gradient(180deg, #5a3a10 0%, #3d260a 100%);
border-color: rgba(255, 152, 0, 0.25);
}
.new-game-form[class*="step-"] .form-footer {
background: rgba(0, 0, 0, 0.35);
border-top-color: rgba(255, 255, 255, 0.12);
}
.new-game-form[class*="step-"] .wizard-summary {
background: rgba(0, 0, 0, 0.35);
border-color: rgba(255, 255, 255, 0.15);
}
.new-game-form[class*="step-"] .screen-title {
color: #fff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.new-game-form[class*="step-"] .player-input {
background: rgba(0, 0, 0, 0.25);
border-color: rgba(255, 255, 255, 0.15);
}
.new-game-form[class*="step-"] .name-input,
.new-game-form[class*="step-"] .game-type-btn,
.new-game-form[class*="step-"] .race-to-btn,
.new-game-form[class*="step-"] .quick-pick-btn {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.15);
}
@keyframes wizardStepIn {
from {
opacity: 0;
transform: translateX(12px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.form-header {
flex-shrink: 0;
padding: clamp(0.4rem, 1.5vh, 1.25rem) var(--space-lg) clamp(0.2rem, 0.8vh, 0.75rem) var(--space-lg);
}
.wizard-summary {
margin-top: clamp(0.25rem, 0.8vh, 0.5rem);
margin-bottom: clamp(0.25rem, 0.8vh, 0.5rem);
padding: clamp(0.5rem, 1.2vh, 0.75rem) var(--space-md);
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.wizard-summary-hint {
margin: 0;
font-size: clamp(0.85rem, 2.2vw, 0.95rem);
line-height: 1.35;
color: var(--color-text-muted, #aaa);
}
.wizard-summary-list {
margin: 0;
display: flex;
flex-direction: column;
gap: clamp(0.2rem, 0.6vh, 0.35rem);
}
.wizard-summary-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.5rem;
min-height: 1.35em;
}
.wizard-summary-label {
margin: 0;
flex: 0 0 auto;
font-size: clamp(0.8rem, 2vw, 0.9rem);
font-weight: 600;
color: var(--color-text-muted, #aaa);
text-transform: none;
}
.wizard-summary-value {
margin: 0;
flex: 1 1 auto;
font-size: clamp(0.95rem, 2.5vw, 1.05rem);
line-height: 1.35;
color: var(--color-text, #eee);
font-weight: 500;
}
.form-content {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0 var(--space-lg);
padding-bottom: var(--space-sm);
min-height: 0;
display: flex;
flex-direction: column;
}
.form-footer {
flex-shrink: 0;
position: sticky;
bottom: 0;
z-index: 2;
background: var(--color-surface);
padding: var(--space-sm) var(--space-lg) calc(var(--space-sm) + var(--safe-area-bottom) + var(--keyboard-offset)) var(--space-lg);
border-top: 1px solid var(--color-border);
}
.progress-indicator {
display: flex;
justify-content: center;
align-items: center;
gap: clamp(0.5rem, 1.5vw, 1rem);
margin-bottom: clamp(0.5rem, 2vh, 1.5rem);
}
.progress-dot {
width: clamp(12px, 2vh, 18px);
height: clamp(12px, 2vh, 18px);
border-radius: 50%;
background: var(--color-border);
opacity: 0.4;
transition: background var(--transition-slow), opacity var(--transition-slow), transform var(--transition-slow), box-shadow var(--transition-slow);
position: relative;
}
.progress-dot.completed {
background: var(--color-primary);
opacity: 0.55;
transform: scale(0.9);
}
.progress-dot.active {
background: var(--color-primary);
opacity: 1;
transform: scale(1.25);
box-shadow: 0 0 0 4px var(--color-primary-light), 0 0 12px rgba(255, 152, 0, 0.4);
}
.quick-pick-container {
flex: 1;
overflow: hidden;
min-height: 0;
display: flex;
flex-direction: column;
}
.quick-pick-btn {
min-width: 80px;
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: 2px solid var(--color-border);
cursor: pointer;
padding: var(--space-md) var(--space-lg);
transition: border-color var(--transition-base), background-color var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
font-weight: 600;
font-family: inherit;
flex-shrink: 0;
touch-action: manipulation;
}
.quick-pick-btn:active:not(.selected) {
transform: scale(0.97);
}
.quick-pick-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-color: var(--color-primary);
}
.choice-btn {
min-width: 160px;
min-height: var(--touch-target-large);
font-size: var(--font-size-lg);
flex: 1;
}
.arrow-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-xs);
width: 100%;
gap: var(--space-sm);
}
.arrow-nav-actions {
display: flex;
align-items: center;
gap: var(--space-md);
}
.arrow-btn {
font-size: 28px;
width: var(--touch-target-comfortable);
height: var(--touch-target-comfortable);
border-radius: 50%;
background: var(--color-secondary);
color: var(--color-text);
border: 2px solid var(--color-border);
box-shadow: var(--shadow-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color var(--transition-base), border-color var(--transition-base);
font-weight: bold;
font-family: inherit;
touch-action: manipulation;
flex-shrink: 0;
}
.arrow-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.arrow-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-color: var(--color-primary);
}
.arrow-btn:active:not(:disabled) {
background: var(--color-secondary-hover);
}
.clear-input-btn {
position: absolute;
right: var(--space-sm);
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
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:focus-visible {
color: var(--color-text);
background: var(--color-secondary);
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.clear-input-btn:active {
color: var(--color-text);
background: var(--color-secondary-hover);
}
.name-input-wrapper {
position: relative;
width: 100%;
}
.character-counter {
font-size: 0.875rem;
margin-top: 4px;
text-align: right;
}
.quick-picks-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.quick-pick-action-btn {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.quick-pick-action-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.quick-pick-action-btn:active {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
.quick-pick-actions-separator {
width: 10px;
height: 1px;
margin: 0 4px;
}
.choice-heading {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
}
.choice-heading:first-child {
margin-top: 0;
}
.game-type-selection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md);
width: 100%;
margin: var(--space-md) 0;
}
.game-type-btn {
background: var(--color-background);
border: 2px solid var(--color-border);
color: var(--color-text);
font-size: var(--font-size-xl);
font-weight: 700;
padding: var(--space-xl);
border-radius: var(--radius-lg);
cursor: pointer;
text-align: center;
transition: border-color var(--transition-base), background-color var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
min-height: var(--touch-target-large);
font-family: inherit;
touch-action: manipulation;
}
.game-type-btn:active:not(.selected) {
transform: scale(0.97);
}
.game-type-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.game-type-btn.selected {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-primary-light);
transform: scale(1.03);
}
.race-to-selection {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: var(--space-md);
width: 100%;
margin: var(--space-md) 0;
}
.race-to-btn {
background: var(--color-background);
border: 2px solid var(--color-border);
color: var(--color-text);
font-size: var(--font-size-xl);
font-weight: 700;
padding: var(--space-lg) var(--space-md);
border-radius: var(--radius-lg);
cursor: pointer;
text-align: center;
transition: border-color var(--transition-base), background-color var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
min-height: var(--touch-target-large);
display: flex;
align-items: center;
justify-content: center;
font-family: inherit;
touch-action: manipulation;
}
.race-to-btn:active:not(.selected) {
transform: scale(0.97);
}
.race-to-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.race-to-btn.selected {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-primary-light);
transform: scale(1.03);
}
/* Match selected styling for quick pick buttons used in BreakRuleStep */
.quick-pick-btn.selected {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-primary-light);
font-weight: 700;
transform: scale(1.02);
}
.custom-race-to {
display: flex;
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,
.player2-input.player-input,
.player3-input.player-input {
border-color: var(--color-border);
background: var(--color-surface);
}
@media (min-width: 768px) and (max-width: 1024px) {
.wizardShell {
padding: var(--space-md) 0;
}
.new-game-form {
min-height: min(82vh, 680px);
max-height: 100%;
}
.screen-title {
font-size: var(--font-size-xxxl);
}
.arrow-btn {
width: 56px;
height: 56px;
font-size: 34px;
}
.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 (min-height: 720px) {
.new-game-form {
min-height: min(88vh, 720px);
}
}
@media (max-width: 767px) {
.wizardShell {
padding: var(--space-xs) 0;
align-items: stretch;
}
.new-game-form {
width: min(96vw, 720px);
min-height: 0;
max-height: 100%;
}
.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: var(--touch-target-min);
height: var(--touch-target-min);
font-size: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.new-game-form {
animation: none;
}
.game-type-btn.selected,
.race-to-btn.selected,
.quick-pick-btn.selected {
transform: none;
}
}
@media (max-height: 560px) {
.screen-title {
margin-bottom: 0.35rem;
}
.progress-indicator {
margin-bottom: 0.35rem;
}
.wizard-summary {
padding: 0.35rem var(--space-sm);
margin-bottom: 0.25rem;
}
.wizard-summary-value {
font-size: clamp(0.9rem, 2.2vw, 1rem);
}
.player-input {
padding: var(--space-sm);
}
.form-header {
padding-top: 0.4rem;
padding-bottom: 0.3rem;
}
.form-footer {
padding-top: 0.3rem;
padding-bottom: calc(0.35rem + var(--safe-area-bottom) + var(--keyboard-offset));
}
}
@media (max-height: 500px) {
.wizardShell {
padding: 0;
}
.new-game-form {
min-height: 0;
max-height: 100%;
width: min(98vw, 720px);
}
.form-header {
padding: var(--space-xs) var(--space-md) var(--space-xs);
}
.screen-title {
font-size: clamp(1rem, 3.5vh, 1.25rem);
margin-bottom: 0.25rem;
}
.wizard-summary {
margin-top: 0.15rem;
margin-bottom: 0.15rem;
padding: 0.25rem var(--space-sm);
}
.choice-heading {
margin-top: 0.5rem;
margin-bottom: 0.35rem;
font-size: clamp(0.95rem, 3vh, 1.1rem);
}
.quick-picks-row {
margin-top: 0;
}
.quick-pick-btn.choice-btn {
min-height: var(--touch-target-min);
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-base);
}
.form-footer {
padding: var(--space-xs) var(--space-md) calc(var(--space-xs) + var(--safe-area-bottom) + var(--keyboard-offset));
}
}
@@ -0,0 +1,96 @@
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--space-lg);
padding-top: calc(var(--space-lg) + var(--safe-area-top));
padding-bottom: calc(var(--space-lg) + var(--safe-area-bottom));
}
.modalContent {
background: var(--color-surface);
padding: var(--space-xl);
border-radius: var(--radius-xl);
width: min(480px, 100%);
max-height: calc(var(--app-height) - var(--space-xl) * 2);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
}
.modalHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
gap: var(--space-md);
}
.modalHeader h3 {
margin: 0;
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--color-text);
}
.closeButton {
background: var(--color-secondary);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--font-size-xxl);
line-height: 1;
color: var(--color-text-muted);
cursor: pointer;
min-width: var(--touch-target-min);
min-height: var(--touch-target-min);
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
}
.closeButton:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.playerList {
max-height: 60vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-sm);
-webkit-overflow-scrolling: touch;
}
.playerItem {
background: var(--color-background);
color: var(--color-text);
border: 2px solid var(--color-border);
padding: var(--space-md) var(--space-lg);
border-radius: var(--radius-md);
text-align: left;
font-size: var(--font-size-lg);
font-weight: 600;
font-family: inherit;
cursor: pointer;
min-height: var(--touch-target-comfortable);
touch-action: manipulation;
transition: border-color var(--transition-base), background-color var(--transition-base);
}
.playerItem:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-color: var(--color-primary);
}
.playerItem:active {
background: var(--color-secondary);
border-color: var(--color-primary);
}
@@ -0,0 +1,42 @@
import { h } from 'preact';
import type { JSX } from 'preact';
import styles from './NewGame.module.css';
interface ProgressIndicatorProps {
currentStep: number;
totalSteps?: number;
style?: JSX.CSSProperties;
}
export function ProgressIndicator({
currentStep,
totalSteps = 7,
style,
}: ProgressIndicatorProps) {
const activeIndex = Math.min(Math.max(currentStep, 1), totalSteps) - 1;
return (
<div
className={styles['progress-indicator']}
style={style}
role="progressbar"
aria-valuenow={currentStep}
aria-valuemin={1}
aria-valuemax={totalSteps}
aria-label={`Schritt ${currentStep} von ${totalSteps}`}
>
{Array.from({ length: totalSteps }, (_, index) => {
const isActive = index === activeIndex;
const isCompleted = index < activeIndex;
const className = [
styles['progress-dot'],
isCompleted ? styles.completed : '',
isActive ? styles.active : '',
].filter(Boolean).join(' ');
return <span key={index} className={className} />;
})}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
# New Game Wizard (`@lib/features/new-game`)
Composable building blocks for the multi-step "start a new game" workflow.
## Exports
- `PlayerNameStep` – Configurable player name capture step with history + quick picks.
- `GameTypeStep` – Game type selector.
- `RaceToStep` – Numeric race-to chooser with infinity support.
- `BreakRuleStep`, `BreakOrderStep` – Break configuration helpers.
- `PlayerSelectModal` – Modal surface for long player lists.
All exports are surfaced via `@lib/features/new-game`.
## Props & Contracts
- Steps expect pure callbacks (`onNext`, `onCancel`) and derive their own UI state.
- Player history arrays control quick-pick ordering. Empty arrays fall back gracefully.
- Styling is shared via `NewGame.module.css` to keep a consistent visual language.
## Integrating the Wizard
```tsx
import { PlayerNameStep } from '@lib/features/new-game';
import { useNewGameWizard } from '@lib/state';
const wizard = useNewGameWizard();
return (
<>
{wizard.newGameStep === 'player1' && (
<PlayerNameStep
stepNumber={1}
title="Name Spieler 1"
label="Spieler 1"
placeholder="Name Spieler 1"
inputId="player1-input"
playerInputClassName={styles['player1-input']}
playerNameHistory={playerHistory}
onNext={(name) => {
wizard.updateGameData({ player1: name });
wizard.nextStep('player2');
}}
onCancel={wizard.resetWizard}
required
requiredErrorMessage="Bitte Namen für Spieler 1 eingeben"
enterKeyHint="next"
showMorePlayersModal
/>
)}
{/* render subsequent steps analogously */}
</>
);
```
@@ -0,0 +1,81 @@
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--space-lg);
padding-top: calc(var(--space-lg) + var(--safe-area-top));
padding-bottom: calc(var(--space-lg) + var(--safe-area-bottom));
}
.modalContent {
width: min(420px, 100%);
background: linear-gradient(180deg, #2a2a2a 0%, var(--color-surface) 100%);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.06);
padding: var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.modalTitle {
margin: 0;
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--color-text);
}
.modalInput {
width: 100%;
min-height: var(--touch-target-comfortable);
font-size: max(16px, var(--font-size-base));
}
.modalActions {
display: flex;
justify-content: stretch;
gap: var(--space-md);
flex-wrap: wrap;
}
.modalActionButton {
flex: 1;
min-width: 120px;
}
.errorText {
color: var(--color-danger);
font-size: var(--font-size-sm);
margin: 0;
font-weight: 500;
}
@media (max-width: 600px) {
.modalOverlay {
padding: 16px;
padding-top: calc(16px + var(--safe-area-top));
padding-bottom: calc(16px + var(--safe-area-bottom));
align-items: stretch;
}
.modalContent {
width: 100%;
flex: 1;
max-height: none;
justify-content: center;
}
.modalActions {
flex-direction: column;
}
.modalActionButton {
width: 100%;
min-width: 0;
}
}
@@ -0,0 +1,87 @@
import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import styles from './NameEntryModal.module.css';
import newGameStyles from '../NewGame.module.css';
import { Button } from '@lib/ui/Button';
interface NameEntryModalProps {
open: boolean;
title: string;
placeholder: string;
inputId: string;
initialValue?: string;
enterKeyHint?: 'next' | 'done';
onClose: () => void;
onConfirm: (name: string) => string | null;
}
export function NameEntryModal({
open,
title,
placeholder,
inputId,
initialValue = '',
enterKeyHint = 'done',
onClose,
onConfirm,
}: NameEntryModalProps) {
const [name, setName] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
setName(initialValue);
setError(null);
window.setTimeout(() => inputRef.current?.focus(), 0);
}, [open, initialValue]);
if (!open) return null;
const handleConfirm = () => {
const validationError = onConfirm(name);
if (validationError) setError(validationError);
};
return (
<div className={styles.modalOverlay} onClick={onClose} role="dialog" aria-modal="true" aria-labelledby={`${inputId}-title`}>
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
<h3 id={`${inputId}-title`} className={styles.modalTitle}>
{title}
</h3>
<input
id={inputId}
ref={inputRef}
className={`${newGameStyles['name-input']} ${styles.modalInput}`}
value={name}
placeholder={placeholder}
autoComplete="off"
autoCapitalize="words"
spellcheck={false}
enterKeyHint={enterKeyHint}
onInput={(e: Event) => {
setName((e.target as HTMLInputElement).value);
if (error) setError(null);
}}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
handleConfirm();
}}
/>
{error && <p className={styles.errorText}>{error}</p>}
<div className={styles.modalActions}>
<Button variant="secondary" size="medium" onClick={onClose} className={styles.modalActionButton}>
Abbrechen
</Button>
<Button variant="primary" size="medium" onClick={handleConfirm} className={styles.modalActionButton}>
Übernehmen
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,245 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from '../NewGame.module.css';
import {
ARIA_LABELS,
ERROR_STYLES,
FORM_CONFIG,
UI_CONSTANTS,
} from '@lib/domain/constants';
import { PlayerSelectModal } from '../steps/PlayerSelectModal';
import { NameEntryModal } from './NameEntryModal';
import type { NewGameData, NewGameStep } from '@lib/domain/types';
import { WizardNav } from './WizardNav';
import { WizardStepForm } from './WizardStepForm';
import { WizardSummary } from './WizardSummary';
interface PlayerNameStepProps {
stepNumber: number;
title: string;
label: string;
placeholder: string;
inputId: string;
playerInputClassName: string;
stepClassName?: string;
playerNameHistory: string[];
onNext: (name: string) => void;
onCancel: () => void;
initialValue?: string;
required?: boolean;
requiredErrorMessage?: string;
enterKeyHint: 'next' | 'done';
showSkipButton?: boolean;
skipLabel?: string;
showMorePlayersModal?: boolean;
prefillNameEntry?: boolean;
onCreateTemporaryName?: (name: string) => void;
data?: NewGameData;
currentStep?: NewGameStep;
}
export function PlayerNameStep({
stepNumber,
title,
label,
placeholder,
inputId,
playerInputClassName,
stepClassName,
playerNameHistory,
onNext,
onCancel,
initialValue = '',
required = true,
requiredErrorMessage,
enterKeyHint,
showSkipButton = false,
skipLabel = 'Überspringen',
showMorePlayersModal = false,
prefillNameEntry = true,
onCreateTemporaryName,
data,
currentStep,
}: PlayerNameStepProps) {
const [value, setValue] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const [isPlayerListOpen, setIsPlayerListOpen] = useState(false);
const [isNameEntryOpen, setIsNameEntryOpen] = useState(false);
const validate = (name: string): string | null => {
const trimmedName = name.trim();
if (required && !trimmedName) {
return requiredErrorMessage ?? 'Bitte Namen eingeben';
}
if (trimmedName.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
return `Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`;
}
return null;
};
const handleSubmit = (e: Event) => {
e.preventDefault();
const trimmedValue = value.trim();
const validationError = validate(trimmedValue);
if (validationError) {
setError(validationError);
return;
}
setError(null);
onNext(trimmedValue);
};
const handleQuickPick = (name: string) => {
setValue(name);
setError(null);
onNext(name);
};
const handleSkip = () => {
setValue('');
setError(null);
onNext('');
};
const handleManualNameSubmit = (name: string): string | null => {
const trimmedName = name.trim();
if (!trimmedName) {
return 'Bitte Namen eingeben';
}
const manualNameError = validate(trimmedName);
if (manualNameError) {
return manualNameError;
}
setValue(trimmedName);
setError(null);
setIsNameEntryOpen(false);
onCreateTemporaryName?.(trimmedName);
onNext(trimmedName);
return null;
};
return (
<WizardStepForm
ariaLabel={ARIA_LABELS.PLAYER_INPUT(label)}
title={title}
currentStep={stepNumber}
stepClassName={stepClassName}
onSubmit={handleSubmit}
summary={
data && currentStep ? (
<WizardSummary data={data} currentStep={currentStep} />
) : undefined
}
footer={
<WizardNav
onBack={onCancel}
nextDisabled={!value.trim()}
middleContent={
showSkipButton ? (
<button
type="button"
onClick={handleSkip}
className={styles['quick-pick-btn']}
>
{skipLabel}
</button>
) : undefined
}
/>
}
>
<div
className={`${styles['player-input']} ${playerInputClassName}`}
style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_LARGE }}
>
<div className={styles['quick-picks-row']}>
{showMorePlayersModal && playerNameHistory.length > 0 && (
<button
type="button"
className={`${styles['quick-pick-btn']} ${styles['quick-pick-action-btn']}`}
onClick={() => setIsPlayerListOpen(true)}
aria-label="Liste anzeigen"
>
Liste anzeigen
</button>
)}
<button
type="button"
className={`${styles['quick-pick-btn']} ${styles['quick-pick-action-btn']}`}
onClick={() => setIsNameEntryOpen(true)}
aria-label={`Name für ${label} eingeben`}
>
Name eingeben
</button>
{playerNameHistory.length > 0 && (
<span className={styles['quick-pick-actions-separator']} aria-hidden="true" />
)}
{playerNameHistory.slice(0, UI_CONSTANTS.MAX_QUICK_PICKS).map((name, idx) => (
<button
type="button"
key={name + idx}
className={`${styles['quick-pick-btn']} ${value === name ? styles['selected'] : ''}`}
onClick={() => handleQuickPick(name)}
aria-label={ARIA_LABELS.QUICK_PICK(name)}
>
{name}
</button>
))}
</div>
</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>
)}
{isPlayerListOpen && (
<PlayerSelectModal
players={playerNameHistory}
onSelect={(name) => {
setIsPlayerListOpen(false);
handleQuickPick(name);
}}
onClose={() => setIsPlayerListOpen(false)}
/>
)}
<NameEntryModal
open={isNameEntryOpen}
title={`Name für ${label}`}
placeholder={placeholder}
inputId={inputId}
initialValue={prefillNameEntry ? value : ''}
enterKeyHint={enterKeyHint}
onClose={() => setIsNameEntryOpen(false)}
onConfirm={handleManualNameSubmit}
/>
{value.length > FORM_CONFIG.CHARACTER_COUNT_WARNING_THRESHOLD && (
<div
className={styles['character-counter']}
style={{ color: value.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH ? '#f44336' : '#ff9800' }}
>
{value.length}/{FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen
</div>
)}
</WizardStepForm>
);
}
@@ -0,0 +1,45 @@
import { h } from 'preact';
import type { ComponentChildren } from 'preact';
import styles from '../NewGame.module.css';
import { ARIA_LABELS } from '@lib/domain/constants';
interface WizardNavProps {
onBack: () => void;
nextDisabled?: boolean;
nextType?: 'submit' | 'button';
nextButtonAriaLabel?: string;
middleContent?: ComponentChildren;
}
export function WizardNav({
onBack,
nextDisabled = false,
nextType = 'submit',
nextButtonAriaLabel = ARIA_LABELS.NEXT,
middleContent,
}: WizardNavProps) {
return (
<div className={styles['arrow-nav']}>
<button
type="button"
className={styles['arrow-btn']}
aria-label={ARIA_LABELS.BACK}
onClick={onBack}
>
&#8592;
</button>
<div className={styles['arrow-nav-actions']}>
{middleContent}
<button
type={nextType}
className={styles['arrow-btn']}
aria-label={nextButtonAriaLabel}
disabled={nextDisabled}
>
&#8594;
</button>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import { h } from 'preact';
import type { ComponentChildren } from 'preact';
import { ProgressIndicator } from '../ProgressIndicator';
import styles from '../NewGame.module.css';
import { UI_CONSTANTS } from '@lib/domain/constants';
interface WizardStepFormProps {
ariaLabel: string;
title: string;
currentStep: number;
onSubmit?: (e: Event) => void;
summary?: ComponentChildren;
children: ComponentChildren;
footer: ComponentChildren;
stepClassName?: string;
}
export function WizardStepForm({
ariaLabel,
title,
currentStep,
onSubmit,
summary,
children,
footer,
stepClassName,
}: WizardStepFormProps) {
return (
<div className={styles.wizardShell}>
<form
className={`${styles['new-game-form']} ${stepClassName ?? ''}`}
onSubmit={onSubmit}
aria-label={ariaLabel}
autoComplete="off"
>
<div className={styles['form-header']}>
<div className={styles['screen-title']}>{title}</div>
<ProgressIndicator currentStep={currentStep} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_MEDIUM }} />
{summary}
</div>
<div className={styles['form-content']}>{children}</div>
<div className={styles['form-footer']}>{footer}</div>
</form>
</div>
);
}
@@ -0,0 +1,134 @@
import { h } from 'preact';
import type { NewGameData, NewGameStep } from '@lib/domain/types';
import { GAME_TYPES, RACE_TO_INFINITY } from '@lib/domain/constants';
import styles from '../NewGame.module.css';
const STEP_ORDER: NewGameStep[] = [
'player1',
'player2',
'player3',
'gameType',
'raceTo',
'breakRule',
'breakOrder',
];
function stepIndex(step: NewGameStep): number {
if (!step) return -1;
return STEP_ORDER.indexOf(step);
}
function isPast(current: NewGameStep, target: NewGameStep): boolean {
return stepIndex(current) > stepIndex(target);
}
function getPlayerNames(data: NewGameData): string[] {
return [data.player1, data.player2, data.player3].filter(Boolean);
}
function getPriorPlayerSummary(data: NewGameData, currentStep: NewGameStep): string | null {
if (currentStep === 'player1') return null;
let names: string[] = [];
if (currentStep === 'player2') {
names = data.player1 ? [data.player1] : [];
} else if (currentStep === 'player3') {
names = [data.player1, data.player2].filter(Boolean);
} else if (isPast(currentStep, 'player3')) {
names = getPlayerNames(data);
}
return names.length > 0 ? names.join(' vs ') : null;
}
function getGameTypeLabel(gameType: NewGameData['gameType']): string | null {
if (!gameType) return null;
const match = GAME_TYPES.find((item) => item.value === gameType);
return match?.label ?? gameType;
}
function formatRaceTo(raceTo: string): string | null {
if (!raceTo) return null;
if (raceTo === RACE_TO_INFINITY || raceTo === 'Infinity') return 'Endlos';
return `Race to ${raceTo}`;
}
function formatBreakRule(rule: NewGameData['breakRule']): string | null {
if (rule === 'winnerbreak') return 'Winnerbreak';
if (rule === 'wechselbreak') return 'Wechselbreak';
return null;
}
function formatBreakOrder(data: NewGameData, breakFirst?: number): string | null {
const idx =
breakFirst ??
(typeof data.breakFirst === 'number' ? data.breakFirst : 0);
if (!idx) return null;
const name = getPlayerNames(data)[idx - 1];
return name ? `${name} breakt zuerst` : null;
}
interface SummaryItem {
label: string;
value: string;
}
interface WizardSummaryProps {
data: NewGameData;
currentStep: NewGameStep;
breakFirst?: number;
}
export function WizardSummary({ data, currentStep, breakFirst }: WizardSummaryProps) {
const items: SummaryItem[] = [];
const players = getPriorPlayerSummary(data, currentStep);
if (players) {
items.push({ label: 'Spieler', value: players });
}
if (isPast(currentStep, 'gameType')) {
const gameType = getGameTypeLabel(data.gameType);
if (gameType) items.push({ label: 'Spielart', value: gameType });
}
if (isPast(currentStep, 'raceTo')) {
const raceTo = formatRaceTo(data.raceTo);
if (raceTo) items.push({ label: 'Race to', value: raceTo });
}
if (isPast(currentStep, 'breakRule')) {
const breakRule = formatBreakRule(data.breakRule);
if (breakRule) items.push({ label: 'Break-Regel', value: breakRule });
}
if (currentStep === 'breakOrder') {
const breakOrder = formatBreakOrder(data, breakFirst);
if (breakOrder) items.push({ label: 'Break-Reihenfolge', value: breakOrder });
}
if (items.length === 0) {
if (currentStep === 'player1') {
return (
<div className={styles['wizard-summary']} aria-live="polite">
<p className={styles['wizard-summary-hint']}>Spieler und Einstellungen werden hier angezeigt.</p>
</div>
);
}
return null;
}
return (
<div className={styles['wizard-summary']} aria-label="Bisherige Auswahl" aria-live="polite">
<dl className={styles['wizard-summary-list']}>
{items.map((item) => (
<div key={item.label} className={styles['wizard-summary-row']}>
<dt className={styles['wizard-summary-label']}>{item.label}</dt>
<dd className={styles['wizard-summary-value']}>{item.value}</dd>
</div>
))}
</dl>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export { PlayerSelectModal } from './steps/PlayerSelectModal';
export { PlayerNameStep } from './components/PlayerNameStep';
export { GameTypeStep } from './steps/GameTypeStep';
export { RaceToStep } from './steps/RaceToStep';
export { BreakRuleStep } from './steps/BreakRuleStep';
export { BreakOrderStep } from './steps/BreakOrderStep';
export { ProgressIndicator } from './ProgressIndicator';
@@ -0,0 +1,115 @@
import { h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import styles from '../NewGame.module.css';
import type { BreakRule } from '@lib/domain/types';
import type { NewGameData } from '@lib/domain/types';
import { WizardNav } from '../components/WizardNav';
import { WizardStepForm } from '../components/WizardStepForm';
import { WizardSummary } from '../components/WizardSummary';
interface BreakOrderStepProps {
players: string[];
rule: BreakRule;
onNext: (first: number, second?: number) => void;
onCancel: () => void;
initialFirst?: number;
initialSecond?: number;
data: NewGameData;
}
export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst = 1, initialSecond, data }: BreakOrderStepProps) => {
const playerCount = players.filter(Boolean).length;
const [first, setFirst] = useState<number>(initialFirst);
const [second, setSecond] = useState<number | undefined>(initialSecond);
useEffect(() => {
if (!initialSecond && rule === 'wechselbreak' && playerCount === 3) {
setSecond(2);
}
}, [initialSecond, rule, playerCount]);
const handleFirst = (idx: number) => {
setFirst(idx);
const isImmediateFlow = rule !== 'wechselbreak' || playerCount !== 3;
if (isImmediateFlow) {
onNext(idx);
}
};
const handleSecond = (idx: number) => {
setSecond(idx);
if (rule === 'wechselbreak' && playerCount === 3 && first > 0) {
onNext(first, idx);
}
};
const handleSubmit = (e: Event) => {
e.preventDefault();
if (rule === 'wechselbreak' && playerCount === 3) {
if (first > 0 && (second ?? 0) > 0) {
onNext(first, second);
}
return;
}
if (first > 0) {
onNext(first);
}
};
return (
<WizardStepForm
ariaLabel="Break-Reihenfolge wählen"
title="Wer hat den ersten Anstoss?"
currentStep={7}
stepClassName={styles['step-breakOrder']}
onSubmit={handleSubmit}
summary={<WizardSummary data={data} currentStep="breakOrder" breakFirst={first} />}
footer={
<WizardNav
onBack={onCancel}
nextDisabled={
(rule === 'wechselbreak' && playerCount === 3) ? !(first > 0 && (second ?? 0) > 0) : !(first > 0)
}
/>
}
>
<div className={styles['choice-heading']}>Wer hat den ersten Anstoss?</div>
<div className={styles['quick-picks-row']}>
{players.filter(Boolean).map((name, idx) => (
<button
key={`first-${idx}`}
type="button"
className={`${styles['quick-pick-btn']} ${styles['choice-btn']} ${first === (idx + 1) ? styles['selected'] : ''}`}
onClick={() => handleFirst(idx + 1)}
aria-label={`Zuerst: ${name}`}
>
{name}
</button>
))}
</div>
{rule === 'wechselbreak' && playerCount === 3 && (
<>
<div className={styles['choice-heading']}>Wer hat den zweiten Anstoss?</div>
<div className={styles['quick-picks-row']}>
{players.filter(Boolean).map((name, idx) => (
<button
key={`second-${idx}`}
type="button"
className={`${styles['quick-pick-btn']} ${styles['choice-btn']} ${second === (idx + 1) ? styles['selected'] : ''}`}
onClick={() => handleSecond(idx + 1)}
aria-label={`Zweites Break: ${name}`}
>
{name}
</button>
))}
</div>
</>
)}
</WizardStepForm>
);
};
@@ -0,0 +1,60 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from '../NewGame.module.css';
import type { BreakRule } from '@lib/domain/types';
import type { NewGameData } from '@lib/domain/types';
import { WizardNav } from '../components/WizardNav';
import { WizardStepForm } from '../components/WizardStepForm';
import { WizardSummary } from '../components/WizardSummary';
interface BreakRuleStepProps {
onNext: (rule: BreakRule) => void;
onCancel: () => void;
initialValue?: BreakRule;
data: NewGameData;
}
export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak', data }: BreakRuleStepProps) => {
const [rule, setRule] = useState<BreakRule>(initialValue ?? 'winnerbreak');
const handleSelect = (nextRule: BreakRule) => {
setRule(nextRule);
onNext(nextRule);
};
const handleSubmit = (e: Event) => {
e.preventDefault();
onNext(rule);
};
return (
<WizardStepForm
ariaLabel="Break-Regel wählen"
title="Break-Regel wählen"
currentStep={6}
stepClassName={styles['step-breakRule']}
onSubmit={handleSubmit}
summary={<WizardSummary data={data} currentStep="breakRule" />}
footer={<WizardNav onBack={onCancel} />}
>
<div className={styles['quick-picks-row']}>
{[
{ key: 'winnerbreak', label: 'Winnerbreak' },
{ key: 'wechselbreak', label: 'Wechselbreak' },
].map(opt => (
<button
key={opt.key}
type="button"
className={`${styles['quick-pick-btn']} ${styles['choice-btn']} ${rule === (opt.key as BreakRule) ? styles['selected'] : ''}`}
onClick={() => handleSelect(opt.key as BreakRule)}
aria-label={`Break-Regel wählen: ${opt.label}`}
>
{opt.label}
</button>
))}
</div>
</WizardStepForm>
);
};
@@ -0,0 +1,58 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from '../NewGame.module.css';
import { GAME_TYPES } from '@lib/domain/constants';
import type { NewGameData } from '@lib/domain/types';
import { WizardNav } from '../components/WizardNav';
import { WizardStepForm } from '../components/WizardStepForm';
import { WizardSummary } from '../components/WizardSummary';
interface GameTypeStepProps {
onNext: (type: string) => void;
onCancel: () => void;
initialValue?: string;
data: NewGameData;
}
export const GameTypeStep = ({ onNext, onCancel, initialValue = '', data }: GameTypeStepProps) => {
const [gameType, setGameType] = useState(initialValue);
const handleSelect = (selectedType: string) => {
setGameType(selectedType);
onNext(selectedType);
};
const handleSubmit = (e: Event) => {
e.preventDefault();
if (gameType) {
onNext(gameType);
}
};
return (
<WizardStepForm
ariaLabel="Spielart auswählen"
title="Spielart auswählen"
currentStep={4}
stepClassName={styles['step-gameType']}
onSubmit={handleSubmit}
summary={<WizardSummary data={data} currentStep="gameType" />}
footer={<WizardNav onBack={onCancel} nextDisabled={!gameType} />}
>
<div className={styles['game-type-selection']}>
{GAME_TYPES.map(({ value, label }) => (
<button
key={value}
type="button"
className={`${styles['game-type-btn']} ${gameType === value ? styles.selected : ''}`}
onClick={() => handleSelect(value)}
>
{label}
</button>
))}
</div>
</WizardStepForm>
);
};
@@ -0,0 +1,45 @@
import { h } from 'preact';
import modalStyles from '../PlayerSelectModal.module.css';
interface PlayerSelectModalProps {
players: string[];
onSelect: (player: string) => void;
onClose: () => void;
}
export const PlayerSelectModal = ({ players, onSelect, onClose }: PlayerSelectModalProps) => (
<div
className={modalStyles.modalOverlay}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="player-select-title"
>
<div className={modalStyles.modalContent} onClick={e => e.stopPropagation()}>
<div className={modalStyles.modalHeader}>
<h3 id="player-select-title">Alle Spieler</h3>
<button
type="button"
className={modalStyles.closeButton}
onClick={onClose}
aria-label="Schließen"
>
×
</button>
</div>
<div className={modalStyles.playerList}>
{players.map(player => (
<button
key={player}
type="button"
className={modalStyles.playerItem}
onClick={() => onSelect(player)}
aria-label={`Spieler wählen: ${player}`}
>
{player}
</button>
))}
</div>
</div>
</div>
);
@@ -0,0 +1,107 @@
import { h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import styles from '../NewGame.module.css';
import {
RACE_TO_QUICK_PICKS,
RACE_TO_DEFAULT,
RACE_TO_INFINITY,
} from '@lib/domain/constants';
import type { NewGameData } from '@lib/domain/types';
import { WizardNav } from '../components/WizardNav';
import { WizardStepForm } from '../components/WizardStepForm';
import { WizardSummary } from '../components/WizardSummary';
interface RaceToStepProps {
onNext: (raceTo: string | number) => void;
onCancel: () => void;
initialValue?: string | number;
gameType?: string;
data: NewGameData;
}
export const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType, data }: RaceToStepProps) => {
const quickPicks = [...RACE_TO_QUICK_PICKS];
const defaultValue = RACE_TO_DEFAULT;
const [raceTo, setRaceTo] = useState<string | number>(
initialValue !== '' ? initialValue : defaultValue
);
const toRaceToValue = (value: string | number) => {
if (value === RACE_TO_INFINITY || value === 'Infinity') return Infinity;
return parseInt(String(value), 10) || 0;
};
useEffect(() => {
if (initialValue === '' || initialValue === undefined) {
setRaceTo(defaultValue);
} else {
setRaceTo(initialValue);
}
}, [defaultValue, initialValue, gameType]);
const handleQuickPick = (value: number | typeof RACE_TO_INFINITY) => {
const selected = value === RACE_TO_INFINITY ? RACE_TO_INFINITY : value;
setRaceTo(selected);
onNext(toRaceToValue(selected));
};
const handleInputChange = (e: Event) => {
const target = e.target as HTMLInputElement;
setRaceTo(target.value);
};
const handleSubmit = (e: Event) => {
e.preventDefault();
onNext(toRaceToValue(raceTo));
};
return (
<WizardStepForm
ariaLabel="Race To auswählen"
title="Race To auswählen"
currentStep={5}
stepClassName={styles['step-raceTo']}
onSubmit={handleSubmit}
summary={<WizardSummary data={data} currentStep="raceTo" />}
footer={<WizardNav onBack={onCancel} nextDisabled={String(raceTo).trim() === ''} />}
>
<div className={styles['endlos-container']}>
<button
type="button"
className={`${styles['race-to-btn']} ${styles['endlos-btn']} ${
raceTo === RACE_TO_INFINITY ? styles.selected : ''
}`}
onClick={() => handleQuickPick(RACE_TO_INFINITY)}
>
Endlos
</button>
</div>
<div className={styles['race-to-selection']}>
{quickPicks.map(value => (
<button
key={value}
type="button"
className={`${styles['race-to-btn']} ${
parseInt(String(raceTo), 10) === value ? styles.selected : ''
}`}
onClick={() => handleQuickPick(value)}
>
{value}
</button>
))}
</div>
<div className={styles['custom-race-to']}>
<input
type="number"
pattern="[0-9]*"
value={raceTo}
onInput={handleInputChange}
className={styles['name-input']}
placeholder="manuelle Eingabe"
/>
</div>
</WizardStepForm>
);
};
+9
View File
@@ -0,0 +1,9 @@
export * from './domain';
export * from './data';
export * from './state';
export * from './ui';
export * from './features/game-list';
export * from './features/game-detail';
export * from './features/game-lifecycle';
export * from './features/new-game';
+38
View File
@@ -0,0 +1,38 @@
# State Layer (`@lib/state`)
Compose domain + data layers into reusable hooks. Hooks are Preact-friendly but React-compatible thanks to the astro-preact compat flag.
## Hooks
- `useGameState`
- Loads/synchronises game collections.
- Exposes CRUD ops (`addGame`, `updateGame`, `deleteGame`), filtering helpers, and cached player history.
- Handles persistence errors and loading states.
- `useNavigation`
- Simple screen router for the 3 major views (list, new game, detail).
- Tracks selected game id.
- `useNewGameWizard`
- Holds transient wizard form state and immutable steps.
- Provides `startWizard`, `resetWizard`, `updateGameData`, `nextStep`.
- `useModal`, `useValidationModal`, `useCompletionModal`
- Encapsulate modal visibility state, ensuring consistent APIs across components.
## Usage
```ts
import { useGameState, useNavigation, useModal } from '@lib/state';
const gameState = useGameState();
const navigation = useNavigation();
const modal = useModal();
// gameState.games, navigation.screen, modal.openModal(), ...
```
## Design Notes
- Hooks avoid direct DOM work—UI components receive ready-to-render props and callbacks.
- Side effects (storage, logging) are delegated to `@lib/data`.
- All exports are re-exported via `@lib/state/index.ts`.
+8
View File
@@ -0,0 +1,8 @@
export { useGameState } from './useGameState';
export {
useModal,
useValidationModal,
useCompletionModal,
} from './useModal';
export { useNavigation, useNewGameWizard } from './useNavigation';
+121
View File
@@ -0,0 +1,121 @@
import { useState, useEffect, useCallback } from 'preact/hooks';
import type { Game, NewGameData, GameFilter } from '@lib/domain/types';
import { GameService } from '@lib/data/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
View File
@@ -0,0 +1,60 @@
import { useState, useCallback } from 'preact/hooks';
import type { ModalState, ValidationState, CompletionModalState } from '@lib/ui/types';
import type { Game } from '@lib/domain/types';
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,
};
}
+82
View File
@@ -0,0 +1,82 @@
import { useState, useCallback } from 'preact/hooks';
import type { NewGameStep, NewGameData } from '@lib/domain/types';
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,
};
}
+114
View File
@@ -0,0 +1,114 @@
.button {
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-weight: 600;
font-family: inherit;
transition: background-color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-fast), opacity 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;
-webkit-user-select: none;
width: 100%;
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.button:active:not(.disabled) {
transform: scale(0.97);
opacity: 0.92;
}
/* Variants */
.primary {
background: linear-gradient(180deg, #ffb74d 0%, var(--color-primary) 100%);
color: #fff;
box-shadow: var(--shadow-sm), 0 0 0 1px rgba(255, 152, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.primary:not(.disabled):active {
background: var(--color-primary-hover);
}
.secondary {
background: linear-gradient(180deg, #3a3a3a 0%, var(--color-secondary) 100%);
color: var(--color-text);
border: 2px solid var(--color-border);
}
.secondary:not(.disabled):active {
background: var(--color-secondary-hover);
}
.danger {
background: linear-gradient(180deg, #ef5350 0%, var(--color-danger) 100%);
color: #fff;
}
.danger:not(.disabled):active {
background: #d32f2f;
}
/* Sizes */
.small {
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
min-height: var(--touch-target-min);
border-radius: var(--radius-md);
}
.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-xl);
min-height: var(--touch-target-large);
border-radius: var(--radius-lg);
font-weight: 700;
}
/* Tablet */
@media (min-width: 768px) {
.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-large);
padding: var(--space-lg) var(--space-xl);
font-size: var(--font-size-lg);
}
.large {
min-height: 72px;
padding: var(--space-xl) var(--space-xxl);
font-size: var(--font-size-xxl);
}
}
/* States */
.disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (prefers-reduced-motion: reduce) {
.button:active:not(.disabled) {
transform: none;
}
}

Some files were not shown because too many files have changed in this diff Show More