Files
bscscore/README.md
T
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

224 lines
7.8 KiB
Markdown

# 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 <repository-url>
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`.