From c876a80b7e316b2df6521c452ec893bade8de0da Mon Sep 17 00:00:00 2001 From: Frank Schwenk Date: Tue, 22 Sep 2026 16:04:47 +0200 Subject: [PATCH] feat: add setup button to edit game details --- src/components/App.tsx | 27 +- src/components/screens/GameDetailScreen.tsx | 5 +- src/lib/domain/constants.ts | 5 + src/lib/domain/gameUtils.ts | 93 ++++- src/lib/domain/types.ts | 14 + src/lib/domain/validation.ts | 80 +++- src/lib/features/game-detail/GameDetail.tsx | 19 +- .../game-detail/GameSetupModal.module.css | 140 +++++++ .../features/game-detail/GameSetupModal.tsx | 358 ++++++++++++++++++ .../game-detail/SetupButton.module.css | 52 +++ src/lib/features/game-detail/SetupButton.tsx | 28 ++ 11 files changed, 814 insertions(+), 7 deletions(-) create mode 100644 src/lib/features/game-detail/GameSetupModal.module.css create mode 100644 src/lib/features/game-detail/GameSetupModal.tsx create mode 100644 src/lib/features/game-detail/SetupButton.module.css create mode 100644 src/lib/features/game-detail/SetupButton.tsx diff --git a/src/components/App.tsx b/src/components/App.tsx index b82c479..12e03c8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -10,8 +10,9 @@ import { 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 type { StandardGame, Game, EndlosGame, GameSetupData } from '@lib/domain/types'; +import { applySetupData, isEndlosGame, isStandardGame } from '@lib/domain/gameUtils'; +import { validateGameSetupData } from '@lib/domain/validation'; import { Layout } from '@lib/ui/Layout'; import GameListScreen from './screens/GameListScreen'; @@ -141,6 +142,27 @@ export default function App() { } }, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, validationModal.showValidation]); + const handleUpdateGameSetup = useCallback(async (setupData: GameSetupData) => { + if (!navigation.currentGameId) return; + + const game = gameState.getGameById(navigation.currentGameId); + if (!game || !isStandardGame(game)) return; + + const validation = validateGameSetupData(setupData); + if (!validation.isValid) { + validationModal.showValidation(validation.errors.join('\n')); + return; + } + + try { + const updatedGame = applySetupData(game, setupData); + await gameState.updateGame(navigation.currentGameId, updatedGame); + } catch (error) { + console.error('Failed to update game setup:', error); + validationModal.showValidation('Spieleinstellungen konnten nicht gespeichert werden.'); + } + }, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, validationModal.showValidation]); + const handleForfeit = async () => { if (!navigation.currentGameId) return; @@ -294,6 +316,7 @@ export default function App() { onUndo={handleUndo} onForfeit={handleForfeit} onBack={navigation.showGameList} + onUpdateGameSetup={handleUpdateGameSetup} /> )} diff --git a/src/components/screens/GameDetailScreen.tsx b/src/components/screens/GameDetailScreen.tsx index 63b811b..1d2b7dc 100644 --- a/src/components/screens/GameDetailScreen.tsx +++ b/src/components/screens/GameDetailScreen.tsx @@ -1,7 +1,7 @@ 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'; +import type { Game, EndlosGame, GameSetupData } from '@lib/domain/types'; interface GameDetailScreenProps { game?: Game; @@ -11,6 +11,7 @@ interface GameDetailScreenProps { onUndo: () => void; onForfeit: () => void; onBack: () => void; + onUpdateGameSetup?: (setupData: GameSetupData) => void; } export default function GameDetailScreen({ @@ -21,6 +22,7 @@ export default function GameDetailScreen({ onUndo, onForfeit, onBack, + onUpdateGameSetup, }: GameDetailScreenProps) { if (!game) { return ( @@ -40,6 +42,7 @@ export default function GameDetailScreen({ onUndo={onUndo} onForfeit={onForfeit} onBack={onBack} + onUpdateGameSetup={onUpdateGameSetup} /> ); diff --git a/src/lib/domain/constants.ts b/src/lib/domain/constants.ts index adfe471..99c8c5f 100644 --- a/src/lib/domain/constants.ts +++ b/src/lib/domain/constants.ts @@ -38,6 +38,11 @@ export const VALIDATION_MESSAGES = { 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', + SCORE_INVALID: 'Punkte müssen ganze Zahlen ≥ 0 sein', + BREAK_RULE_REQUIRED: 'Break-Regel ist erforderlich', + BREAK_ORDER_INVALID: 'Break-Reihenfolge ist ungültig', + CURRENT_BREAKER_INVALID: 'Aktueller Breaker ist ungültig', + RACE_TO_BELOW_SCORE: 'Race-to muss mindestens dem höchsten Punktestand entsprechen', } as const; export const BREAKPOINTS = { diff --git a/src/lib/domain/gameUtils.ts b/src/lib/domain/gameUtils.ts index c96b75c..88756cc 100644 --- a/src/lib/domain/gameUtils.ts +++ b/src/lib/domain/gameUtils.ts @@ -1,4 +1,4 @@ -import type { Game, StandardGame, EndlosGame } from './types'; +import type { Game, GameSetupData, StandardGame, EndlosGame } from './types'; /** * Game utility functions for common operations @@ -12,6 +12,97 @@ export function isStandardGame(game: Game): game is StandardGame { return !('players' in game); } +function setupActivePlayerCount(data: GameSetupData): number { + return data.player3?.trim() ? 3 : 2; +} + +function defaultBreakOrder(playerCount: number): number[] { + return playerCount === 3 ? [1, 2, 3] : [1, 2]; +} + +function normalizeBreakFields( + data: GameSetupData, + playerCount: number, +): { breakOrder: number[]; currentBreakerIdx: number } { + const expectedPlayers = playerCount === 3 ? [1, 2, 3] : [1, 2]; + const order = data.breakOrder ?? []; + const orderValid = + order.length === playerCount && + new Set(order).size === playerCount && + order.every((p) => expectedPlayers.includes(p)) && + expectedPlayers.every((p) => order.includes(p)); + + if (!orderValid) { + return { breakOrder: defaultBreakOrder(playerCount), currentBreakerIdx: 0 }; + } + + const idx = data.currentBreakerIdx; + if (!Number.isInteger(idx) || idx < 0 || idx >= order.length) { + return { breakOrder: order, currentBreakerIdx: 0 }; + } + + return { breakOrder: order, currentBreakerIdx: idx }; +} + +export function gameToSetupData(game: StandardGame): GameSetupData { + const playerCount = game.player3?.trim() ? 3 : 2; + const breakOrder = game.breakOrder ?? defaultBreakOrder(playerCount); + + return { + player1: game.player1, + player2: game.player2, + player3: game.player3, + score1: game.score1, + score2: game.score2, + score3: game.score3, + gameType: game.gameType, + raceTo: game.raceTo, + breakRule: game.breakRule ?? 'winnerbreak', + breakOrder, + currentBreakerIdx: game.currentBreakerIdx ?? 0, + }; +} + +export function applySetupData(game: StandardGame, data: GameSetupData): StandardGame { + const playerCount = setupActivePlayerCount(data); + const { breakOrder, currentBreakerIdx } = normalizeBreakFields(data, playerCount); + + const scores = [data.score1, data.score2]; + if (playerCount === 3) { + scores.push(data.score3 ?? 0); + } + + let status = game.status; + if (data.raceTo !== Infinity && scores.some((score) => score >= data.raceTo)) { + status = 'completed'; + } + + const updated: StandardGame = { + ...game, + player1: data.player1.trim(), + player2: data.player2.trim(), + score1: data.score1, + score2: data.score2, + gameType: data.gameType, + raceTo: data.raceTo, + breakRule: data.breakRule, + breakOrder, + currentBreakerIdx, + status, + updatedAt: new Date().toISOString(), + }; + + if (playerCount === 3 && data.player3?.trim()) { + updated.player3 = data.player3.trim(); + updated.score3 = data.score3 ?? 0; + } else { + delete updated.player3; + delete updated.score3; + } + + return updated; +} + export function formatGameTime(dateString: string): string { const date = new Date(dateString); const now = new Date(); diff --git a/src/lib/domain/types.ts b/src/lib/domain/types.ts index 4a9f7ec..941e26e 100644 --- a/src/lib/domain/types.ts +++ b/src/lib/domain/types.ts @@ -71,4 +71,18 @@ export interface NewGameData { export type NewGameStep = 'player1' | 'player2' | 'player3' | 'gameType' | 'raceTo' | 'breakRule' | 'breakOrder' | null; +export interface GameSetupData { + player1: string; + player2: string; + player3?: string; + score1: number; + score2: number; + score3?: number; + gameType: GameType; + raceTo: number; + breakRule: BreakRule; + breakOrder: number[]; + currentBreakerIdx: number; +} + export type GameFilter = 'all' | 'active' | 'completed'; \ No newline at end of file diff --git a/src/lib/domain/validation.ts b/src/lib/domain/validation.ts index 3c1e37b..ddbee88 100644 --- a/src/lib/domain/validation.ts +++ b/src/lib/domain/validation.ts @@ -1,5 +1,5 @@ import { APP_CONFIG, VALIDATION_MESSAGES } from './constants'; -import type { NewGameData } from './types'; +import type { GameSetupData, NewGameData } from './types'; export interface ValidationResult { isValid: boolean; @@ -80,6 +80,84 @@ export function sanitizePlayerName(name: string): string { .replace(/[^\w\s-]/g, ''); // Remove special characters except spaces and hyphens } +function activePlayerCountFromSetup(data: GameSetupData): number { + return data.player3?.trim() ? 3 : 2; +} + +function isValidScore(value: number | undefined, required: boolean): boolean { + if (value === undefined) return !required; + return Number.isInteger(value) && value >= 0; +} + +export function validateGameSetupData(data: GameSetupData): ValidationResult { + const errors: string[] = []; + + const player1Validation = validatePlayerName(data.player1); + const player2Validation = validatePlayerName(data.player2); + errors.push(...player1Validation.errors); + errors.push(...player2Validation.errors); + + const playerNames = [data.player1.trim(), data.player2.trim()]; + const hasThirdPlayer = Boolean(data.player3?.trim()); + + if (hasThirdPlayer) { + const player3Validation = validatePlayerName(data.player3!); + errors.push(...player3Validation.errors); + playerNames.push(data.player3!.trim()); + } + + const nonEmptyNames = playerNames.filter((name) => name.length > 0); + if (new Set(nonEmptyNames).size !== nonEmptyNames.length) { + errors.push(VALIDATION_MESSAGES.DUPLICATE_PLAYER_NAMES); + } + + const thirdScoreRequired = hasThirdPlayer; + if (!isValidScore(data.score1, true) || !isValidScore(data.score2, true) || !isValidScore(data.score3, thirdScoreRequired)) { + errors.push(VALIDATION_MESSAGES.SCORE_INVALID); + } + + if (!data.gameType) { + errors.push(VALIDATION_MESSAGES.GAME_TYPE_REQUIRED); + } + + const raceTo = data.raceTo; + if (raceTo !== Infinity && (!Number.isInteger(raceTo) || raceTo <= 0)) { + errors.push(VALIDATION_MESSAGES.RACE_TO_INVALID); + } else if (raceTo !== Infinity) { + const maxScore = Math.max(data.score1, data.score2, data.score3 ?? 0); + if (raceTo < maxScore) { + errors.push(VALIDATION_MESSAGES.RACE_TO_BELOW_SCORE); + } + } + + if (!data.breakRule) { + errors.push(VALIDATION_MESSAGES.BREAK_RULE_REQUIRED); + } + + const activeCount = activePlayerCountFromSetup(data); + const expectedPlayers = activeCount === 3 ? [1, 2, 3] : [1, 2]; + const order = data.breakOrder ?? []; + const orderValid = + order.length === activeCount && + new Set(order).size === activeCount && + order.every((p) => expectedPlayers.includes(p)) && + expectedPlayers.every((p) => order.includes(p)); + + if (!orderValid) { + errors.push(VALIDATION_MESSAGES.BREAK_ORDER_INVALID); + } + + const breakerIdx = data.currentBreakerIdx; + if (!Number.isInteger(breakerIdx) || breakerIdx < 0 || breakerIdx >= order.length) { + errors.push(VALIDATION_MESSAGES.CURRENT_BREAKER_INVALID); + } + + return { + isValid: errors.length === 0, + errors, + }; +} + export function validateRaceTo(value: string): ValidationResult { const errors: string[] = []; diff --git a/src/lib/features/game-detail/GameDetail.tsx b/src/lib/features/game-detail/GameDetail.tsx index 60ce54d..baddfef 100644 --- a/src/lib/features/game-detail/GameDetail.tsx +++ b/src/lib/features/game-detail/GameDetail.tsx @@ -3,7 +3,9 @@ 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'; +import type { Game, EndlosGame, GameSetupData } from '@lib/domain/types'; +import { SetupButton } from './SetupButton'; +import { GameSetupModal } from './GameSetupModal'; interface GameDetailProps { game: Game | undefined; @@ -13,6 +15,7 @@ interface GameDetailProps { onUndo?: () => void; onForfeit?: () => void; onBack: () => void; + onUpdateGameSetup?: (setupData: GameSetupData) => void; } interface AnimatedScoreProps { @@ -49,8 +52,9 @@ function AnimatedScore({ value, tapping }: AnimatedScoreProps) { /** * Game detail view for a single game. */ -const GameDetail = ({ game, onFinishGame, onUpdateScore, onUndo, onBack }: GameDetailProps) => { +const GameDetail = ({ game, onFinishGame, onUpdateScore, onUndo, onBack, onUpdateGameSetup }: GameDetailProps) => { const [tappingPlayer, setTappingPlayer] = useState(null); + const [setupOpen, setSetupOpen] = useState(false); if (!game || !isStandardGame(game)) return null; @@ -154,6 +158,17 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUndo, onBack }: GameD })} + setSetupOpen(true)} disabled={isCompleted} /> + { + onUpdateGameSetup?.(data); + setSetupOpen(false); + }} + onCancel={() => setSetupOpen(false)} + /> +
+
+ +
+ + + {playerLabels.map(({ num, name, score }) => ( +
+ Spieler {num} +
+ { + const v = (e.target as HTMLInputElement).value; + if (num === 1) updateForm({ player1: v }); + else if (num === 2) updateForm({ player2: v }); + else updateForm({ player3: v }); + }} + aria-label={`Name Spieler ${num}`} + autoComplete="off" + /> + { + const v = parseInt((e.target as HTMLInputElement).value, 10); + const safe = Number.isNaN(v) ? 0 : Math.max(0, v); + if (num === 1) updateForm({ score1: safe }); + else if (num === 2) updateForm({ score2: safe }); + else updateForm({ score3: safe }); + }} + aria-label={`Punkte Spieler ${num}`} + /> +
+
+ ))} + +
+ Spieltyp +
+ {GAME_TYPES.map(({ value, label }) => ( + + ))} +
+
+ +
+ Race To +
+ +
+
+ {RACE_TO_QUICK_PICKS.map((value) => ( + + ))} +
+
+ +
+
+ +
+ Break-Regel +
+ {( + [ + { key: 'winnerbreak' as BreakRule, label: 'Winnerbreak' }, + { key: 'wechselbreak' as BreakRule, label: 'Wechselbreak' }, + ] as const + ).map((opt) => ( + + ))} +
+
+ +
+ Wer breakt? +
+ {playerLabels.map(({ num, name }) => ( + + ))} +
+
+ + {errors.length > 0 && ( +
+
    + {errors.map((err) => ( +
  • {err}
  • + ))} +
+
+ )} +
+ +
+
+ + +
+
+ + + ); +} diff --git a/src/lib/features/game-detail/SetupButton.module.css b/src/lib/features/game-detail/SetupButton.module.css new file mode 100644 index 0000000..6832f40 --- /dev/null +++ b/src/lib/features/game-detail/SetupButton.module.css @@ -0,0 +1,52 @@ +/* SetupButton — bottom-left; mirrors FullscreenToggle placement */ +.setupButton { + position: fixed; + bottom: calc(var(--space-md) + var(--safe-area-bottom)); + left: calc(var(--space-md) + var(--safe-area-left)); + 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; +} + +.setupButton:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.setupButton:active:not(:disabled) { + opacity: 0.8; + background-color: var(--color-secondary-hover); +} + +.setupButton:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.setupButton svg { + width: 24px; + height: 24px; +} + +@media (min-width: 768px) { + .setupButton { + width: var(--touch-target-large); + height: var(--touch-target-large); + } + + .setupButton svg { + width: 28px; + height: 28px; + } +} diff --git a/src/lib/features/game-detail/SetupButton.tsx b/src/lib/features/game-detail/SetupButton.tsx new file mode 100644 index 0000000..dd9259c --- /dev/null +++ b/src/lib/features/game-detail/SetupButton.tsx @@ -0,0 +1,28 @@ +import { h } from 'preact'; +import styles from './SetupButton.module.css'; + +interface SetupButtonProps { + onClick: () => void; + disabled?: boolean; +} + +export function SetupButton({ onClick, disabled }: SetupButtonProps) { + return ( + + ); +}