# BSC Score - Pool Scoring Application 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 - **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 **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`). ## Architecture Reusable code lives under `src/lib` so the core can be embedded outside the Astro shell. - **`@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) `src/components` is a thin Astro host: `App.tsx` orchestrates screens and islands. Module docs: `src/lib/docs/architecture.md` and per-package READMEs. ## Getting Started ### Prerequisites - Node.js 18+ - npm ### Installation ```bash git clone cd bscscore npm install npm run dev ``` Dev server: `http://localhost:3000` ### Scripts ```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 ``` ### Docker build ```bash docker run -it -v $(pwd):/app -w /app --rm node:latest npx astro build ``` ## 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`.