- 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
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 andGameService@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
git clone <repository-url>
cd bscscore
npm install
npm run dev
Dev server: http://localhost:3000
Scripts
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
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.
# 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.
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; Carddefault|elevated|outlined
UI copy and validation messages: @lib/domain/constants (VALIDATION_MESSAGES, ARIA_LABELS). Product tone: SOUL.md.
Stack
Deploy
npm run build → dist/ → nginx via compose.yml on score.bsc-gp.de.