2026-09-22 16:04:47 +02:00
2025-10-30 15:03:41 +01:00
2025-06-05 18:08:47 +02:00
2026-04-16 13:20:48 +02:00

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

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; Card default | elevated | outlined

UI copy and validation messages: @lib/domain/constants (VALIDATION_MESSAGES, ARIA_LABELS). Product tone: SOUL.md.

Stack

  • Astro 5 + Preact
  • TypeScript strict, CSS Modules, Vite
  • Playwright for recorded E2E flows

Deploy

npm run build → dist/ → nginx via compose.yml on score.bsc-gp.de.

S
Description
Tablet-first offline PWA for live pool/billiards scoring at the table, built for BSC club use.
https://score.bsc-gp.de
Readme 2.3 MiB
Languages
HTML 72.1%
TypeScript 20.2%
CSS 6.7%
JavaScript 0.8%
Astro 0.2%