IPSC/USPSA Scoring Application — a full-stack app for managing practical shooting matches, scoring shooters, and computing results. Inspired by WinMSS and Practiscore, designed to run both as a networked web app on LAN and as a standalone desktop application.
- What It Does
- Architecture Overview
- Local Domain Names
- Tech Stack
- Getting Started
- Project Structure
- Critical Files
- Database Schema
- Scoring Rules
- Scoring Flow
- Authentication
- WinMSS Import
- Electron Desktop App
- Internationalization
- Windows Compatibility
- Coding Principles
IPSCScore covers the full lifecycle of an IPSC/USPSA shooting match:
- Match Management — Create matches, define stages (with scoring type, target layout, max points), upload stage diagrams.
- Shooter Registration — Maintain a persistent shooter database, register shooters into matches with per-match division/category/power factor overrides.
- Live Scoring — Range officers enter scores per-shooter per-stage: time, per-target hits (A/C/D/M/NS), penalties, steel hits. Mobile-first UI with 44px+ touch targets and sticky save bar.
- Results & Rankings — Automatic hit factor calculation, per-division ranking, stage percent/points, overall match results. Public results view auto-refreshes every 30 seconds.
- Data Export — PDF score sheets, CSV import/export for shooters/registrations/scores, full database backup/restore.
- Legacy Import — Import shooter data and scores from WinMSS
.mdbdatabase files.
The app is designed for use on a local network at a shooting range: multiple range officers can score different stages simultaneously on tablets/phones, while competitors view live results on a big screen or their own devices.
┌─────────────────────────────────────────────────────┐
│ Electron Shell │
│ (optional — bundles everything for desktop use) │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Frontend │ │ Backend │ │ PostgreSQL │ │
│ │ React SPA │◄─┤ Hono API │◄─┤ (embedded) │ │
│ │ :5173 │ │ :3001 │ │ :5432 │ │
│ └──────────┘ └──────────────┘ └────────────────┘ │
│ │
│ mDNS: vysledky.local (results) │
│ hodnotenie.local (scoring) │
└─────────────────────────────────────────────────────┘
Without Electron, the same stack runs via Docker Compose or directly with npm run dev — the frontend proxies API calls to the backend, which connects to any PostgreSQL instance.
The Electron app advertises two mDNS/Bonjour .local domains on the LAN so devices on the range can discover the server by name instead of IP address:
| Domain | Purpose | Auth Required |
|---|---|---|
| vysledky.local | Public results view | No — auto-refreshes every 30s |
| hodnotenie.local | Stage scoring login | Yes — stage password |
| localhost:3001 | Admin access | Yes — admin password |
vysledky.local(Slovak for "results") lands on the public results page — no login needed, live-updating.hodnotenie.local(Slovak for "scoring") lands on the stage login page for range officers.- Android doesn't support mDNS natively — use
http://<IP>:3001instead. - Windows requires Bonjour Print Services for
.localresolution.
The backend determines the domain mode from the Host header (getDomainMode() in app.ts), injects window.__DOMAIN_MODE__ into the HTML, and the frontend routes accordingly.
| Layer | Technology |
|---|---|
| Frontend | React 19 + Vite 6 + TypeScript + Tailwind CSS 4 + Flowbite React |
| State | Zustand (7 stores) |
| i18n | i18next (English + Slovak) |
| Backend | Hono 4 (Node.js) + TypeScript |
| Database | PostgreSQL 16 |
| DB Driver | postgres npm package |
| PDF Export | jsPDF + jspdf-autotable (client-side) |
| CSV | csv-parse (server import), server-generated CSV export |
| Desktop | Electron 34 + electron-builder |
| mDNS | dnssd-advertise (Bonjour/Avahi) |
| Dev | Docker Compose (hot reload for frontend & backend) |
# Start everything — PostgreSQL, backend, frontend
docker compose up
# App available at http://localhost:5173
# API at http://localhost:3001
# PostgreSQL on port 5434 (host)# 1. Start PostgreSQL (requires local instance on port 5432)
# 2. Copy and configure environment
cp .env.example .env
# 3. Backend
cd backend && npm install && npm run dev
# 4. Frontend (in another terminal)
cd frontend && npm install && npm run dev# Full build (rebuilds frontend + backend + bundles + packages)
npm run build:mac # Universal macOS (arm64 + x64)
npm run build:win # Windows x64
npm run build:linux # Linux x64Important: Always rebuild from clean state before packaging. Never package from stale
dist/artifacts.rm -rf frontend/dist npm run build:frontend npm run build:backend cd electron && npm run bundle-backend
See .env.example:
POSTGRES_DB=ipscscore
POSTGRES_USER=ipscscore
POSTGRES_PASSWORD=ipscscore_dev
DATABASE_URL=postgresql://ipscscore:ipscscore_dev@postgres:5432/ipscscore
PORT=3001
UPLOAD_DIR=/app/uploads
NODE_ENV=development
VITE_API_URL=http://localhost:3001IPSCScore/
├── backend/ # Hono API server
│ └── src/
│ ├── index.ts # Entry point — Hono server, runs migrations, mounts routes
│ ├── app.ts # App setup, domain mode middleware, HTML injection
│ ├── env.ts # Environment config
│ ├── db/
│ │ ├── client.ts # PostgreSQL connection
│ │ ├── migrate.ts # Migration runner
│ │ └── migrations/ # 12 numbered SQL migration files
│ ├── middleware/
│ │ ├── auth.ts # Token-based auth middleware
│ │ ├── cors.ts # CORS handling
│ │ ├── errorHandler.ts
│ │ ├── requestLogger.ts
│ │ └── scoreLock.ts # Score locking logic
│ ├── routes/
│ │ ├── auth.ts # Login, logout, password management
│ │ ├── backup.ts # Database backup/restore
│ │ ├── import.ts # Data import
│ │ ├── matches.ts # Match CRUD
│ │ ├── registrations.ts # Shooter registration
│ │ ├── results.ts # Results & calculations
│ │ ├── scoring.ts # Score submission & recalculation
│ │ ├── shooters.ts # Shooter CRUD
│ │ ├── stages.ts # Stage CRUD
│ │ ├── uploads.ts # File uploads
│ │ └── winmssImport.ts # WinMSS .mdb import
│ └── utils/
│ ├── scoringCalc.ts # Core scoring algorithms (CRITICAL)
│ ├── fileStorage.ts # Stage image upload handling
│ ├── winmssMapper.ts # WinMSS data mapping
│ ├── pgBin.ts # PostgreSQL binary utilities
│ └── unaccent.ts # Unaccent text utility
│
├── frontend/ # React SPA
│ └── src/
│ ├── main.tsx # React entry
│ ├── App.tsx # Root component
│ ├── theme.ts # Tailwind/Flowbite theme
│ ├── components/
│ │ ├── auth/ # AdminLoginPage, StageLoginPage
│ │ ├── layout/ # AppLayout, Header, LanUrlBadge, TabBar
│ │ ├── match/ # Match CRUD modals + list/detail
│ │ ├── registration/ # Shooter registration + bulk operations
│ │ ├── results/ # Results tables, public view, export
│ │ ├── scoring/ # Scoring sheets (core UI)
│ │ │ ├── shared/ # Reusable scoring widgets (TimeInput, HitCell, etc.)
│ │ │ └── sheets/ # Scoring type-specific sheets (IPSC, Steel, IDPA, etc.)
│ │ ├── settings/ # App settings, theme, language, WinMSS import
│ │ ├── shared/ # Shared components (CSV import, form fields, bulk toolbar)
│ │ ├── shooter/ # Shooter database management
│ │ └── stage/ # Stage CRUD, image upload, print score sheets
│ ├── hooks/ # 11 custom hooks (auth, scoring, device, etc.)
│ ├── stores/ # 7 Zustand stores
│ ├── services/
│ │ └── api.ts # Typed fetch wrappers for all backend endpoints
│ ├── types/ # TypeScript type definitions
│ ├── utils/ # Client-side scoring, PDF, time formatting
│ └── i18n/ # i18next config + en.json, sk.json locales
│
├── electron/ # Electron desktop wrapper
│ ├── src/
│ │ ├── main.ts # Main process — window, menu, PG lifecycle
│ │ ├── preload.ts # Preload script (exposes getDomainUrls)
│ │ ├── pg-manager.ts # Embedded PostgreSQL start/stop/seed
│ │ ├── db-config-dialog.ts # DB config UI
│ │ ├── port80.ts # Port 80 redirect (macOS only)
│ │ └── logger.ts # File logging
│ ├── scripts/
│ │ ├── afterPack.js # Thins universal PG binaries per architecture
│ │ ├── bundle-backend.mjs # esbuild bundle for backend
│ │ ├── download-pg.sh # Downloads platform-specific PostgreSQL
│ │ └── export-seed-data.sh # Exports seed data dump
│ └── resources/
│ ├── seed-data.dump # Initial database seed
│ ├── icon.png, icon.svg
│ └── pg/ # Embedded PostgreSQL binaries (downloaded)
│
├── docker-compose.yml # PostgreSQL + backend + frontend
├── .env.example # Environment template
└── package.json # npm workspaces (backend, frontend, electron)
These files contain the core domain logic — bugs here directly affect scoring accuracy:
| File | Why It's Critical |
|---|---|
backend/src/utils/scoringCalc.ts |
Pure functions for Comstock/Virginia Count/Fixed Time/Chrono scoring. The most important file to get right. |
backend/src/routes/scoring.ts |
Score save (transaction-wrapped) + stage recalculation using SQL window functions for atomic ranking. |
frontend/src/utils/scoring.ts |
Client-side mirror of scoringCalc.ts for live score preview before saving. |
frontend/src/components/scoring/ScoringSheet.tsx |
Mobile-first per-shooter score entry — the core scoring UI. |
backend/src/app.ts |
Domain mode detection, HTML injection for .local routing. |
electron/src/pg-manager.ts |
Embedded PostgreSQL lifecycle — start, stop, seed data import on first launch. |
electron/src/main.ts |
Electron main process — window management, mDNS advertising, PG lifecycle. |
backend/src/utils/winmssMapper.ts |
WinMSS .mdb data mapping — aggregated score handling, tag/region resolution. |
7 tables with cascading deletes:
matches ─────┬──► stages (cascade)
├──► match_registrations (cascade)
│ └──► stage_scores (cascade)
│ ├──► target_scores (cascade)
│ └──► chrono_results (cascade)
shooters ─────────────────► (RESTRICT if registered in any match)
Key design decisions:
match_registrationshas nullable override columns (division,category,power_factor) that fall through to the shooter's defaults when NULL. This allows per-match property changes without mutating the shooter record.- Per-target score storage in
target_scoresis essential for "best N hits per target" (Comstock) and Virginia Count extra-hit detection. admin_sessionstable stores admin auth tokens (24h expiry).app_settingstable stores admin password hash and app configuration.
Migrations: 12 numbered SQL files in backend/src/db/migrations/ — run automatically on backend startup.
| Zone | Major | Minor | Penalty | Points |
|---|---|---|---|---|
| A | 5 | 5 | Miss | -10 |
| C | 4 | 3 | No-shoot | -10 |
| D | 2 | 1 | FTSA | -10 |
| Steel | 5 | 5 | Procedural | -10 |
- Comstock — Best N hits per target × PF values. Hit factor = net_points / time.
- Virginia Count — Same as Comstock + extra shot/hit/stacking penalties (-10 each). No steel.
- Fixed Time — Misses are NO penalty. Overtime shots = -5. Score = net_points directly (no hit factor).
- Chrono — PF = (bullet_weight × avg_velocity) / 1000. USPSA Major ≥ 165, IPSC Major ≥ 170. Minor ≥ 125.
stage_percent = (HF / highest_HF) × 100stage_points = (stage_percent / 100) × max_points- DQ shooters get 0 stage_points across all stages
- DNF shooters get 0 on the missed stage only
- Range master selects stage → selects shooter → fills scoring sheet (time + per-target hits).
- On save: backend calculates
raw_points/penalties/net_points/hit_factorusingscoringCalc.ts. - Score save is wrapped in a database transaction — safe for concurrent use by multiple range officers.
- After save: triggers
recalculateStage()using SQL CTEs with window functions for atomic per-division ranking (no JS loops, no interleaving). - Frontend shows live score preview (client-side
scoring.ts) before saving.
Three access levels, determined by domain or login:
| Access | How | What They Can Do |
|---|---|---|
| Public | vysledky.local or any URL |
View results only, no login needed |
| Stage Officer | hodnotenie.local + stage password |
Enter/edit scores for assigned stage |
| Admin | localhost:3001 + admin password |
Full access: create matches, register shooters, manage stages, change settings |
- Admin auth uses password + bcrypt-hashed token stored in
admin_sessionstable (24h expiry). - Default admin password:
admin(change on first use via Settings). isLocalNetworkis only for UI routing (which login form to show), not for granting admin access.
The app can import shooter data and scores from WinMSS .mdb database files:
- WinMSS stores aggregated totals per shooter per stage (e.g.,
ScoreA=22, ScoreC=8), not per-target breakdowns. - WinMSS
ScoreAincludes steel hits — steel scores 5 points (same as A), so steel is counted in ScoreA. Must NOT add steel×5 separately. calculateAggregatedScore()bypasses per-target "best N" capping entirely.distributeHits()fills targets with A→C→D→miss respecting HPP (hits per paper) capacity for display purposes.- Tags and regions are resolved from WinMSS lookup tables (
tblTag,tblTypeRegion).
The Electron build bundles everything into a standalone desktop application:
- Frontend — built by Vite, served as static files.
- Backend — bundled by esbuild into a single JS file.
- PostgreSQL — platform-specific binaries downloaded into
electron/resources/pg/.
On first launch, pg-manager.ts initializes a PostgreSQL data directory, starts the server, and imports seed data from seed-data.dump (using pg_restore --disable-triggers). A .seeded marker prevents re-import on subsequent starts.
The afterPack hook thins universal Mach-O binaries per-architecture so @electron/universal can merge them correctly for macOS universal builds.
mDNS advertising (dnssd-advertise) starts after the backend is ready, making the app discoverable as vysledky.local and hodnotenie.local on the LAN.
The app supports English and Slovak via i18next:
- Locale files:
frontend/src/i18n/locales/en.json,sk.json - Language selector in Settings
- All UI strings are externalized
- PostgreSQL DLLs must be in the same directory as the EXEs (not
lib/). pg_ctl start -wcan hang — the app useswaitReady()TCP check instead.- Always use
execFile(notexec) to avoidcmd.exequoting issues. - mDNS requires Bonjour Print Services on Windows.
- Port 80 redirect is macOS-only.
- Windows ARM64 can run x64 binaries through emulation (requires enabling).
- Separate components in separate files — Each component gets its own file. Files exceeding ~150 lines should be split.
- No duplicate code — Extract common logic into hooks and exported functions.
- Non-UI logic lives in hooks — States, functions,
useEffect, computed variables must be in custom hooks. Components should only contain JSX + hook calls. - No prop drilling — Use Zustand stores or shared hooks instead of passing props through multiple levels.
- Call hooks and functions directly — Variables, functions, hooks should be called inside the component that uses them, not passed down.
- Only pass callbacks for parent reactions — Only pass a callback from child to parent if the parent needs to react to it (e.g.,
onClick). Never pass data or handlers the child could access itself.