91 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
Frank Schwenk 68434f885d feat(game-14.1): Implement undo, forfeit, log, and stats
- Implements a robust undo feature using a state stack.
- Adds a 'Forfeit' button to allow a player to concede the game.
- Introduces a 'Game Log' to track all turns, fouls, and re-racks.
- Calculates and displays post-game statistics (highest run, avg. pots/turn).
- All changes are related to issue #21.
2025-06-22 11:05:51 +02:00
Frank Schwenk aa5ef1c5b2 feat(14.1): Implement foul system
Implements a comprehensive foul system for the 14.1 game mode as per issue #20.

- **`src/components/GameDetail141.jsx`**

    - Adds `handleFoul` function to manage standard (-1pt) and break (-2pt) fouls.

    - Implements logic for the 3-consecutive-foul rule, applying a -15pt penalty.

    - Adds foul counters and a visual warning for players with 2 consecutive fouls.

    - Resets the consecutive foul counter on a legal (non-foul) turn.

- **`src/components/GameDetail.module.css`**

    - Adds styles for foul buttons (`.foul-btn`).

    - Adds styles for the foul counter indicator (`.foul-indicator`) and warning (`.foul-warning`).

This commit fulfills the requirements for issue #20. The `.gitea` file is left unstaged as it points to the next issue to be worked on.
2025-06-22 10:43:21 +02:00
Frank Schwenk 6a25c18153 feat(game-14-1): Implement re-rack logic and scoring hooks
Implements the re-rack functionality and prepares the scoring system for foul integration, as detailed in issue #19.

- Adds '+14' and '+15' re-rack buttons to the game view.
- Creates a  function to update the number of balls on the table.
- Modifies the  function to accept an optional  argument, preparing it for the next phase of development.

Closes #19
2025-06-20 16:07:38 +02:00
Frank Schwenk a71c65852d feat(game-14-1): Implement phase 1 foundation
Implements the foundational UI and logic for the 14.1 Endless game mode, as detailed in issue #18.

- Adds a new  component to handle the specific game view.
- Introduces a modal within the game view to select the starting player.
- Replaces text input with a button grid for selecting remaining balls.
- Updates  to correctly initialize the 14.1 game state.

Closes #18
2025-06-20 15:35:38 +02:00
Frank Schwenk 875e9c8795 feat: Implement rematch functionality
Refs #17

Adds a 'Rematch' button to the game completion modal.

Introduces a 'handleRematch' function in 'App.jsx' to create a new game with the same players and settings as the previous game.

The 'onRematch' handler is passed down to the 'GameCompletionModal' component.

Adds '.gitea' to '.gitignore' to prevent tracking local issue context.
2025-06-20 13:35:42 +02:00
Frank Schwenk 429d479f69 feat: Implement score increment on click and cleanup CSS
- Implemented score increment functionality directly on the score display in GameDetail.jsx.

- Deleted obsolete ValidationModal.module.css to finalize style consolidation.

- This resolves all outstanding tasks for the refactor and adds a minor feature enhancement.

- Closes #1
2025-06-20 13:01:30 +02:00
Frank Schwenk dbc173f57b feat: optimize player selection for touch input
- Increases the number of quick-pick buttons from 4 to 10.
- Adds a '...' button that appears when more than 10 players exist in history.
- Clicking '...' opens a scrollable modal listing all past players for easy selection.
- This provides a much faster player selection flow on touch devices.

Closes #4
2025-06-20 11:03:37 +02:00
Frank Schwenk b466dd2a0a feat: complete wizard navigation for all steps
- Adds forward navigation arrows to the 'Game Type' and 'Race To' steps in the new game wizard.
- Unifies navigation logic across all five steps.
- Users can now review their selections before proceeding.

Closes #11
2025-06-20 10:44:27 +02:00
Frank Schwenk 14fd711858 refactor: Update Race To step UI and logic
- Renames 'Offen' to 'Endlos' and moves it to a separate line.
- Extends quick-pick buttons to include 1-9.
- Removes 'Custom' button and makes numeric input always visible.
- Updates placeholder text for the custom input.
- Closes #10
2025-06-20 10:23:14 +02:00
Frank Schwenk 1c77661dbc feat: Implement Game Type selection step
- Adds the 'GameTypeStep' to the new game wizard.
- Features large, touch-friendly buttons for selecting game types.
- Selection automatically proceeds to the next step.
- Includes progress indicator and back navigation.
- Closes #9
2025-06-20 10:16:13 +02:00
Frank Schwenk 47554cdd27 feat: Implement Player 3 input step
- Implemented the third step of the new game creation wizard for Player 3's name input.
- The step is optional and includes a 'Skip' button.
- Includes autosuggestions from player history and quick-pick buttons.
- Aligned styling and layout with previous steps, including fixes for button alignment.
- Closes #8
2025-06-20 10:10:32 +02:00
Frank Schwenk a2b618ce16 feat(wizard): Player 2 step and multi-step navigation
- Add Player2Step component for player 2 name input
- Implement multi-step wizard logic in App.jsx
- Player 1/2 steps now allow forward/back navigation
- Left arrow on Player 2 returns to Player 1, not abort
- Prepare placeholders for further steps

Closes #7
2025-06-18 20:59:05 +02:00
Frank Schwenk 76ef005cda feat(wizard): Player 1 step UI overhaul
- Quick-pick now advances to next step
- Added clear (×) icon to input field
- Replaced navigation buttons with large left/right arrows
- All controls are touch-friendly and visually prominent

Closes #6
2025-06-18 20:48:28 +02:00
Frank Schwenk d1379985f3 refactor: deduplicate modal/button styles and enforce global utility usage
- Consolidated all modal-related styles into Modal.module.css; ValidationModal.module.css is now deprecated
- All main action/navigation buttons in NewGame and GameDetail use global .btn/.nav-buttons utility classes
- Removed duplicate utility classes from component CSS files
- Fixed .fullscreenToggle class naming for consistency
- Cleaned up component CSS to only contain component-specific styles
- Updated GameCompletionModal to use shared modal styles

This ensures DRY, maintainable, and consistent styling across the app.
2025-06-06 16:42:11 +02:00
Frank Schwenk 209df5d9f2 refactor: apply Astro, Preact, and general best practices to src
- Refactored all components in src/components to:
  - Use arrow function components and prop destructuring
  - Add JSDoc for all exported components
  - Improve accessibility (aria-labels, roles, etc.)
  - Use correct key usage in lists
  - Add comments for non-obvious logic
  - Use modern event handler patterns and memoization where appropriate
- Refactored src/pages/index.astro:
  - Removed <html>, <head>, and <body> (should be in layout)
  - Used semantic <main> for main content
  - Kept only necessary imports and markup
- Refactored src/styles/index.css:
  - Removed duplicate rules
  - Ensured only global resets/utilities are present
  - Added comments for clarity
  - Ensured no component-specific styles are present
  - Used consistent formatting

Brings the codebase in line with modern Astro and Preact best practices, improves maintainability, accessibility, and code clarity.
2025-06-06 16:28:57 +02:00
Frank Schwenk 7cb79f5ee3 fix: modal overlay and game screen styling
- Move modal overlay CSS to global stylesheet for reliable overlay behavior
- Render GameCompletionModal outside main container for true overlay effect
- Refactor GameCompletionModal to use global overlay and local content styles
- Fix player score layout, color, and button styling on game detail screen
- Add global .modal and .modal.show classes to index.css
- Clean up CSS modules for modal content and responsive design

Fixes #<issue_number>
2025-06-06 15:56:04 +02:00
Frank Schwenk d81c375f1e feat(new-game): prefill with last game, add clear-all button
- New Game form is now prefilled with the last created game's values
- Added 'Felder leeren' (Clear All) button at the top to reset all fields
- Improves speed and UX for repeated game entry

Refs #1
2025-06-06 13:11:03 +02:00
Frank Schwenk c845b0cb51 refactor(new-game): modernize UI, remove player selects
- Refactored New Game screen to use a modern, card-like, dark-themed layout
- Removed select dropdowns for previous players, now only datalist+input for player names
- Updated paddings, backgrounds, borders, and font sizes for a visually consistent, modern look
- No logic changes, only markup and style

Refs #1
2025-06-06 12:54:13 +02:00
Frank Schwenk b44b013f58 refactor: move filter bar to GameList and fix button styling
- Moved filter button bar from App.jsx to GameList.jsx for better separation of concerns.
- Updated GameList to accept filter/setFilter props and render the filter bar internally.
- Moved .new-game-button styles to global CSS for consistent styling.
- Ensured filter button styles remain in GameList.module.css.
- Improves modularity and UI consistency.
2025-06-06 12:22:55 +02:00
Frank Schwenk 8384d08393 refactor: migrate UI to Preact components and remove legacy Astro/JS
- Replaced all .astro components with .jsx Preact components and added corresponding CSS modules.
- Updated index.astro to use the new App Preact component; removed legacy script and Astro imports.
- Deleted obsolete .astro component files and main JS logic (src/scripts/index.js, public/scripts/index.js).
- Updated astro.config.mjs for Preact integration.
- Updated package.json and package-lock.json to include @astrojs/preact and preact.
- Updated tsconfig.json for Preact JSX support.
- Refactored index.css to keep only global resets and utility styles.
- All changes relate to Gitea issue #1 (refactor to astro app).

Migrates the UI from Astro/vanilla JS to a modular Preact component architecture, removing all legacy code and aligning the project with modern best practices.
Refs #1
2025-06-06 11:58:29 +02:00
Frank Schwenk de07d6e7a2 refactor: modularize screens, styles, and logic
- Split monolithic index.astro into Astro components for each screen and modal
- Moved all styles to src/styles/index.css
- Moved all JS logic to src/scripts/index.js and public/scripts/index.js
- Updated event wiring and removed inline handlers for best practice
- Ensured all components and scripts are integrated and functional

Refs #1
2025-06-05 19:51:01 +02:00
Frank Schwenk 38916b026d Commit staged changes 2025-06-05 18:27:02 +02:00
Frank Schwenk f2e11e74e1 Document and automate Cursor rules adaptation for Astro web app 2025-06-05 18:12:59 +02:00
Frank Schwenk 03cb869a0a Commit staged changes 2025-06-05 18:08:47 +02:00
Frank Schwenk 501e064711 Add PWA assets, update index.astro, set .gitea
- Added PWA-related files: LICENSE, favicon.ico, icon-192.png, icon-512.png, manifest.json, service-worker.js in public/
- Deleted legacy favicon.svg
- Major update to src/pages/index.astro: implements full responsive UI, PWA manifest, service worker registration, and touch/mobile optimizations
- Added/updated .gitea to track current Gitea issue for workflow compliance

Purpose: Implements PWA support and refactors the main page for the Astro app, aligning with the migration and refactor described in issue #1.

Refs #1
2025-06-05 12:14:59 +02:00
Frank Schwenk 7f6c0292cb Initialize Astro project structure and clean up legacy files\n\n- Added Astro project scaffolding and configuration:\n - .gitignore, .gitmodules, .vscode/extensions.json, .vscode/launch.json\n - astro.config.mjs, package.json, package-lock.json, tsconfig.json\n - public/favicon.svg, src/pages/index.astro\n- Added .cursor as a submodule (note: unstaged changes remain)\n- Removed legacy files from previous project setup:\n - LICENSE, TODO.md, favicon.ico, icon-192.png, icon-512.png, index.html, manifest.json, service-worker.js\n- Establishes a clean foundation for new development using Astro\n\nRefs #1 2025-06-05 12:05:02 +02:00
117 changed files with 14746 additions and 1947 deletions
+1
View File
@@ -0,0 +1 @@
https://gitea.schwenk.online/froxxxy/bscscore/issues/30
+32
View File
@@ -0,0 +1,32 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
.gitea
dev/.gitea
# Playwright test artifacts
playwright-report/
test-results/
playwright/.cache/
View File
+4
View File
@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}
+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
- Track scores for different billiards game types (8-Ball, 9-Ball, 10-Ball, 14/1)
- Support for "Race to X" games
- Real-time score tracking
- Game history with active and completed games
- Player name history and quick selection
- Mobile-optimized touch interface
- Offline support with local storage
- Dark theme design
- **Game types**: 8-Ball, 9-Ball, 10-Ball; Race-to or 14/1 Endlos
- **Live scoring**: Tap a player's score card for +1; undo for the last point
- **New-game wizard**: 7 steps with running summary (`WizardSummary`) and per-step colored backgrounds
- **Break tracking**: Winnerbreak / Wechselbreak, break-order selection, live break indicator on scoreboard
- **Player history**: Quick-pick from previously used names
- **Game list**: Filter (Alle / Aktiv / Abgeschlossen), tap card to open, delete with confirmation
- **Rematch**: Start a new game with the same players after completion
- **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
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
## Architecture
## Development
Reusable code lives under `src/lib` so the core can be embedded outside the Astro shell.
The application is built using:
- Vanilla JavaScript (ES6+)
- HTML5
- CSS3
- LocalStorage for data persistence
- **`@lib/domain`** — Types, constants, validation, pure helpers
- **`@lib/data`** — IndexedDB adapters and `GameService`
- **`@lib/state`** — Hooks (`useGameState`, `useNavigation`, `useNewGameWizard`, modals)
- **`@lib/ui`** — Stateless primitives (Button, Card, Modal, Layout) with CSS modules
- **`@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:
- `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
## Getting Started
## Features in Detail
### Prerequisites
### Core Features
- Score tracking for multiple billiards game types
- Player name history with quick selection
- Game status management (active/completed)
- Local storage for offline functionality
- Mobile-optimized interface
- Node.js 18+
- npm
### User Interface
- Dark theme design
- Touch-friendly controls
- Responsive layout
- Game type selection
- Player name management
### Installation
### Data Management
- Local storage persistence
- Game history tracking
- Player name history
- Status filtering
```bash
git clone <repository-url>
cd bscscore
npm install
npm run dev
```
## Contributing
Dev server: `http://localhost:3000`
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
### Scripts
## 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
-89
View File
@@ -1,89 +0,0 @@
# TODO List
## High Priority
1. Add game statistics
- Track win/loss ratio per player
- Show total games played
- Display average scores
- Show favorite game types per player
2. Implement game history
- Log score changes with timestamps
- Show who made which changes
- Allow reviewing game progression
3. Add confirmation for game completion
- Show final score summary
- Display winner announcement
- Option to undo completion
4. Improve error handling
- Validate all user inputs
- Show meaningful error messages
- Add recovery options for data issues
## Medium Priority
5. Add player profiles
- Player avatars/images
- Personal statistics
- Preferred game types
- Nickname support
6. Implement game rules
- Show rules for each game type
- Add rule validation for scoring
- Display fouls and penalties
7. Add search and filtering
- Search for specific games/players
- Filter by date ranges
- Sort by various criteria
- Advanced filtering options
8. Improve game type management
- Custom game types
- Configurable scoring rules
- Special game modes
## Lower Priority
9. Add themes and customization
- Dark/light mode toggle
- Custom color schemes
- Font size adjustments
- Layout options
10. Implement data backup
- Export to different formats
- Automatic backups
- Data recovery options
11. Add social features
- Share game results
- Player rankings
- Tournament support
- Challenge system
12. Improve accessibility
- Screen reader support
- Keyboard navigation
- High contrast mode
- Voice input support
## Nice to Have
13. Add animations and effects
- Score change animations
- Victory celebrations
- Transition effects
- Sound effects
14. Implement achievements
- Player milestones
- Special records
- Achievement badges
- Progress tracking
15. Add multi-language support
- Language selection
- Localized content
- RTL support
- Custom translations
+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.
+51
View File
@@ -0,0 +1,51 @@
// @ts-check
import { fileURLToPath } from 'node:url';
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';
// https://astro.build/config
export default defineConfig({
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
-1746
View File
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
{
"name": "BSC Score",
"short_name": "BSC Score",
"description": "Pool Scoring App für den Billard Sport Club",
"lang": "de",
"start_url": ".",
"display": "fullscreen",
"background_color": "#1a1a1a",
"theme_color": "#000000",
"icons": [
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+5815
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "bscscore",
"type": "module",
"version": "2.2.0",
"scripts": {
"dev": "astro dev --host",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"test:record": "playwright codegen http://localhost:3000",
"test:e2e": "playwright test",
"test:replay": "playwright test"
},
"dependencies": {
"@astrojs/preact": "^4.1.3",
"astro": "^5.15.5",
"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,
// },
});
View File
View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Before

Width:  |  Height:  |  Size: 364 B

After

Width:  |  Height:  |  Size: 364 B

Before

Width:  |  Height:  |  Size: 601 B

After

Width:  |  Height:  |  Size: 601 B

+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;
});
}),
);
});
-31
View File
@@ -1,31 +0,0 @@
const CACHE_NAME = 'bscscore-v1';
const URLS_TO_CACHE = [
'/',
'/index.html',
'/manifest.json',
'/favicon.ico',
'/icon-192.png',
'/icon-512.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(URLS_TO_CACHE))
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)))
)
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response =>
response || fetch(event.request).catch(() => caches.match('/index.html'))
)
);
});
+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>
);
}
@@ -0,0 +1,47 @@
/* FullscreenToggle — top-right keeps bottom controls clear on scoreboard */
.fullscreenToggle {
position: fixed;
top: calc(var(--space-md) + var(--safe-area-top));
right: calc(var(--space-md) + var(--safe-area-right));
width: var(--touch-target-comfortable);
height: var(--touch-target-comfortable);
border-radius: 50%;
background-color: var(--color-secondary);
border: 2px solid var(--color-border);
color: var(--color-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-md);
z-index: 9999;
transition: background-color var(--transition-base), opacity var(--transition-base);
touch-action: manipulation;
}
.fullscreenToggle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.fullscreenToggle:active {
opacity: 0.8;
background-color: var(--color-secondary-hover);
}
.fullscreenToggle svg {
width: 24px;
height: 24px;
}
@media (min-width: 768px) {
.fullscreenToggle {
width: var(--touch-target-large);
height: var(--touch-target-large);
}
.fullscreenToggle svg {
width: 28px;
height: 28px;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { h } from 'preact';
import { useCallback } from 'preact/hooks';
import styles from './FullscreenToggle.module.css';
/**
* Button to toggle fullscreen mode.
* @returns {import('preact').VNode}
*/
const FullscreenToggle = () => {
// Toggle fullscreen mode for the document
const handleToggle = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
}, []);
return (
<button
id="fullscreen-toggle"
className={styles.fullscreenToggle}
onClick={handleToggle}
title="Vollbild umschalten"
aria-label="Vollbild umschalten"
type="button"
>
<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"/>
</svg>
<span className="sr-only">Vollbild</span>
</button>
);
};
export default FullscreenToggle;
@@ -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;
}
}
+34
View File
@@ -0,0 +1,34 @@
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,
className = '',
'aria-label': ariaLabel,
...rest
}: ButtonProps) {
const classNames = [
styles.button,
styles[variant],
styles[size],
disabled && styles.disabled,
className,
].filter(Boolean).join(' ');
return (
<button
className={classNames}
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
{...rest}
>
{children}
</button>
);
}
+60
View File
@@ -0,0 +1,60 @@
.card {
border-radius: var(--radius-lg);
transition: border-color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-fast);
background: linear-gradient(180deg, #2a2a2a 0%, var(--color-surface) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.default {
border: 1px solid var(--color-border);
}
.elevated {
border: 1px solid var(--color-border);
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.outlined {
background: transparent;
border: 2px solid var(--color-border);
}
.clickable {
cursor: pointer;
border: none;
text-align: left;
width: 100%;
font-family: inherit;
color: inherit;
}
.clickable:active {
transform: scale(0.99);
}
.clickable:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.padding-none {
padding: 0;
}
.padding-small {
padding: var(--space-sm);
}
.padding-medium {
padding: var(--space-md);
}
.padding-large {
padding: var(--space-lg);
}
@media (prefers-reduced-motion: reduce) {
.clickable:active {
transform: none;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { h } from 'preact';
import styles from './Card.module.css';
interface CardProps {
children: any;
variant?: 'default' | 'elevated' | 'outlined';
padding?: 'none' | 'small' | 'medium' | 'large';
className?: string;
onClick?: () => void;
}
export function Card({
children,
variant = 'default',
padding = 'medium',
className = '',
onClick
}: CardProps) {
const classNames = [
styles.card,
styles[variant],
styles[`padding-${padding}`],
onClick && styles.clickable,
className,
].filter(Boolean).join(' ');
const Component = onClick ? 'button' : 'div';
return (
<Component className={classNames} onClick={onClick}>
{children}
</Component>
);
}
+60
View File
@@ -0,0 +1,60 @@
.layout {
height: var(--app-height);
overflow: hidden;
background-color: var(--color-background);
color: var(--color-text);
display: flex;
flex-direction: column;
}
.content {
flex: 1;
max-width: 1100px;
margin: 0 auto;
padding: var(--space-md);
padding-bottom: calc(var(--space-md) + var(--safe-area-bottom));
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.screen {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
animation: screenFadeIn var(--transition-slow) ease-out;
}
@keyframes screenFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.screen {
animation: none;
}
}
@media (min-width: 768px) {
.content {
padding: var(--space-lg) var(--space-xl);
padding-bottom: calc(var(--space-lg) + var(--safe-area-bottom));
}
}
@media (max-width: 767px) {
.content {
padding: var(--space-sm) var(--space-md);
padding-bottom: calc(var(--space-sm) + var(--safe-area-bottom));
}
}
+33
View File
@@ -0,0 +1,33 @@
import { h } from 'preact';
import styles from './Layout.module.css';
interface LayoutProps {
children: any;
className?: string;
}
export function Layout({ children, className = '' }: LayoutProps) {
return (
<div className={`${styles.layout} ${className}`}>
<a href="#main-content" className="skip-link">
Zum Hauptinhalt springen
</a>
<div className={styles.content} id="main-content">
{children}
</div>
</div>
);
}
interface ScreenProps {
children: any;
className?: string;
}
export function Screen({ children, className = '' }: ScreenProps) {
return (
<div className={`${styles.screen} ${className}`}>
{children}
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
/* Consolidated modal styles */
.modal {
display: none;
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.8);
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));
padding-left: calc(var(--space-lg) + var(--safe-area-left));
padding-right: calc(var(--space-lg) + var(--safe-area-right));
}
.modal.show {
display: flex;
justify-content: center;
align-items: center;
}
.modal-content {
background-color: var(--color-surface);
padding: var(--space-xl);
border-radius: var(--radius-xl);
width: min(500px, 100%);
max-height: calc(var(--app-height) - var(--space-xl) * 2);
overflow-y: auto;
position: relative;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
gap: var(--space-md);
}
.modal-title {
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--color-text);
}
.close-button {
font-size: var(--font-size-xxl);
line-height: 1;
cursor: pointer;
color: var(--color-text-muted);
background: var(--color-secondary);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
min-width: var(--touch-target-min);
min-height: var(--touch-target-min);
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
flex-shrink: 0;
}
.close-button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
color: var(--color-text);
}
.modal-body {
margin-bottom: var(--space-lg);
}
.modal-message {
font-size: var(--font-size-lg);
color: var(--color-text-secondary);
line-height: 1.5;
}
.modal-footer {
display: flex;
flex-direction: column;
justify-content: stretch;
gap: var(--space-md);
}
@media (min-width: 480px) {
.modal-footer {
flex-direction: row;
justify-content: flex-end;
flex-wrap: wrap;
}
}
.modal-button {
min-height: var(--touch-target-large);
padding: var(--space-md) var(--space-xl);
font-size: var(--font-size-lg);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--font-size-base);
font-weight: 600;
font-family: inherit;
touch-action: manipulation;
flex: 1;
min-width: 120px;
}
.modal-button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.modal-button.cancel {
background-color: var(--color-secondary);
color: var(--color-text);
border: 2px solid var(--color-border);
}
.modal-button.confirm {
background-color: var(--color-danger);
color: #fff;
}
.modal-button.confirm:active {
background-color: #d32f2f;
}
.modal-button.cancel:active {
background-color: var(--color-secondary-hover);
}
+36
View File
@@ -0,0 +1,36 @@
import { h } from 'preact';
import styles from './Modal.module.css';
interface ModalProps {
open: boolean;
title: string;
message: string;
onCancel: () => void;
onConfirm: () => void;
}
/**
* Generic modal dialog for confirmation.
*/
const Modal = ({ open, title, message, onCancel, onConfirm }: ModalProps) => {
if (!open) return null;
return (
<div className={styles['modal'] + ' ' + styles['show']} role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div className={styles['modal-content']}>
<div className={styles['modal-header']}>
<span className={styles['modal-title']} id="modal-title">{title}</span>
<button type="button" className={styles['close-button']} onClick={onCancel} aria-label="Schließen">×</button>
</div>
<div className={styles['modal-body']}>
<div className={styles['modal-message']}>{message}</div>
</div>
<div className={styles['modal-footer']}>
<button type="button" className={styles['modal-button'] + ' ' + styles['cancel']} onClick={onCancel} aria-label="Abbrechen">Abbrechen</button>
<button type="button" className={styles['modal-button'] + ' ' + styles['confirm']} onClick={onConfirm} aria-label="Löschen">Löschen</button>
</div>
</div>
</div>
);
};
export default Modal;
+35
View File
@@ -0,0 +1,35 @@
# UI Primitives (`@lib/ui`)
Reusable presentation components with co-located CSS modules. No business logic; just view concerns.
## Components
- `Button` – Variant + size aware button with shared styling.
- `Card` – Basic container with optional elevated/outlined variants.
- `Layout` / `Screen` – Page chrome primitives used by the Astro shell.
- `Modal` – Generic confirmation modal (title, message, confirm/cancel).
- `ValidationModal` – Specialized modal for validation feedback.
## Types
- `types.ts` defines `ButtonProps`, modal state types, etc. Re-exported via `@lib/ui`.
## Usage
```tsx
import { Button, Card, Layout } from '@lib/ui';
<Layout>
<Card variant="elevated">
<Button variant="primary">Start</Button>
</Card>
</Layout>
```
## Styling
- Each component ships with a `.module.css` file.
Astro/Vite handles module scoping automatically—consumers simply import the component.
- Custom class names can be injected through the exposed `className` props when required.
+33
View File
@@ -0,0 +1,33 @@
import { h } from 'preact';
import styles from './Modal.module.css';
interface ValidationModalProps {
open: boolean;
message: string;
onClose: () => void;
}
/**
* Modal for displaying validation errors.
*/
const ValidationModal = ({ open, message, onClose }: ValidationModalProps) => {
if (!open) return null;
return (
<div className={styles['modal'] + ' ' + styles['show']} id="validation-modal" role="alertdialog" aria-modal="true" aria-labelledby="validation-modal-title">
<div className={styles['modal-content']}>
<div className={styles['modal-header']}>
<span className={styles['modal-title']} id="validation-modal-title">Fehler</span>
<button type="button" className={styles['close-button']} onClick={onClose} aria-label="Schließen">×</button>
</div>
<div className={styles['modal-body']}>
<div className={styles['modal-message']}>{message}</div>
</div>
<div className={styles['modal-footer']}>
<button type="button" className={styles['modal-button'] + ' ' + styles['cancel']} onClick={onClose} aria-label="OK">OK</button>
</div>
</div>
</div>
);
};
export default ValidationModal;
+7
View File
@@ -0,0 +1,7 @@
export * from './types';
export { Button } from './Button';
export { Card } from './Card';
export { Layout, Screen } from './Layout';
export { default as Modal } from './Modal';
export { default as ValidationModal } from './ValidationModal';
+33
View File
@@ -0,0 +1,33 @@
export interface ModalState {
open: boolean;
gameId?: number | null;
}
export interface ValidationState {
open: boolean;
message: string;
}
import type { Game } from './game';
export interface CompletionModalState {
open: boolean;
game: Game | null;
}
export interface AppScreen {
current: 'game-list' | 'new-game' | 'game-detail';
}
export interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
children?: React.ReactNode;
onClick?: () => void;
'aria-label'?: string;
style?: React.CSSProperties;
className?: string;
type?: 'button' | 'submit' | 'reset';
[key: string]: unknown; // Allow additional props
}
+49
View File
@@ -0,0 +1,49 @@
---
import "../styles/index.css";
import App from "../components/App";
---
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content">
<title>BSC Score – Live-Scoring am Billardtisch</title>
<meta name="description" content="Live-Scoring für 8-, 9- und 10-Ball am Billardtisch. Offline, schnell, ein Tipp pro Punkt.">
<!-- PWA Meta tags -->
<meta name="theme-color" content="#1a1a1a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<link rel="manifest" href="/manifest.webmanifest">
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png">
<link rel="apple-touch-icon" href="/icon-192.png">
<style>
#app-root {
height: var(--app-height, 100dvh);
width: 100%;
overflow: hidden;
}
</style>
</head>
<body>
<div id="app-root">
<App client:only="preact" />
</div>
<script>
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch((error) => {
console.error('Service worker registration failed:', error);
});
});
}
</script>
</body>
</html>
+271
View File
@@ -0,0 +1,271 @@
/* Global resets and utility styles */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
/* Design system tokens */
:root {
--app-height: 100dvh;
--keyboard-offset: 0px;
/* Safe area insets */
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-left: env(safe-area-inset-left, 0px);
/* Colors */
--color-primary: #ff9800;
--color-primary-hover: #ffa726;
--color-primary-light: rgba(255, 152, 0, 0.15);
--color-secondary: #333;
--color-secondary-hover: #444;
--color-background: #1a1a1a;
--color-surface: #222;
--color-surface-hover: #2a2a2a;
--color-text: #fff;
--color-text-secondary: #ccc;
--color-text-muted: #999;
--color-border: #333;
--color-success: #4caf50;
--color-danger: #f44336;
--color-warning: #ff9800;
/* Player card colors (scoreboard) */
--color-player-1: #2e4631;
--color-player-2: #2a3b52;
--color-player-3: #333;
/* Spacing system — 8px base */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-xxl: 3rem;
/* Typography — system font stack only (no webfonts) */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-xxl: 1.5rem;
--font-size-xxxl: 2rem;
/* Touch targets */
--touch-target-min: 48px;
--touch-target-comfortable: 56px;
--touch-target-large: 64px;
/* Border radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.25);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.35);
/* Transitions */
--transition-fast: 0.1s ease;
--transition-base: 0.15s ease;
--transition-slow: 0.2s ease;
}
/* Tablet landscape — primary target device */
@media (min-width: 768px) {
:root {
--touch-target-min: 48px;
--touch-target-comfortable: 56px;
--touch-target-large: 64px;
--space-md: 1.25rem;
--space-lg: 2rem;
--space-xl: 2.5rem;
--font-size-base: 1.125rem;
--font-size-lg: 1.25rem;
--font-size-xl: 1.5rem;
--font-size-xxl: 1.75rem;
--font-size-xxxl: 2.25rem;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
html,
body,
#app-root {
height: var(--app-height, 100dvh);
min-height: var(--app-height, 100dvh);
}
body {
font-family: var(--font-family);
background-color: var(--color-background);
color: var(--color-text);
overscroll-behavior: none;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
padding-top: var(--safe-area-top);
padding-left: var(--safe-area-left);
padding-right: var(--safe-area-right);
overflow: hidden;
}
#app-root {
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Interactive elements — touch-friendly */
button,
[role="button"],
a,
input,
select,
.btn,
.button {
touch-action: manipulation;
}
button,
.btn,
.button {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* Global focus-visible */
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Inputs — 16px minimum prevents iOS zoom-on-focus */
input, select {
min-height: var(--touch-target-comfortable);
padding: var(--space-md);
font-size: max(16px, var(--font-size-base));
font-family: inherit;
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
transition: border-color var(--transition-base);
}
input:focus-visible, select:focus-visible {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
/* Legacy .btn utility (used in GameDetail controls) */
.btn {
min-height: var(--touch-target-comfortable);
padding: var(--space-md) var(--space-lg);
color: var(--color-text);
background: var(--color-secondary);
border: none;
border-radius: var(--radius-md);
font-size: var(--font-size-base);
font-weight: 600;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
text-decoration: none;
}
.btn-primary {
background: var(--color-primary);
color: #fff;
}
.btn--secondary {
background: var(--color-secondary);
color: var(--color-text);
border: 2px solid #555;
}
.btn--finish {
background: transparent;
color: var(--color-text-secondary);
border: 2px solid var(--color-border);
}
.btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.btn:active:not(:disabled) {
opacity: 0.9;
transform: scale(0.98);
}
.btn-primary:active:not(:disabled) {
background: var(--color-primary-hover);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Screen reader only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Skip link */
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: var(--color-primary);
color: #fff;
padding: var(--space-sm) var(--space-md);
text-decoration: none;
border-radius: var(--radius-sm);
z-index: 10000;
font-weight: 600;
}
.skip-link:focus-visible {
top: calc(6px + var(--safe-area-top));
}
+9
View File
@@ -0,0 +1,9 @@
declare module '*.module.css' {
const classes: { [key: string]: string };
export default classes;
}
declare module '*.css' {
const content: string;
export default content;
}
+7
View File
@@ -0,0 +1,7 @@
{
"status": "failed",
"failedTests": [
"b5da854a7c119c37c9d5-8782df6f17e49724b9a2",
"1012373defdfca68178e-d28214002c7ecee79090"
]
}

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