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
- Android App (standalone)
- iOS App (standalone)
- Releases
- 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/PT), 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
.mdbdatabases or.cabmatch exports.
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 │ │
│ └──────────┘ └──────────────┘ └────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
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 backend listens on port 3001. Devices on the same Wi-Fi can reach:
| URL | Purpose | Auth Required |
|---|---|---|
http://<lan-ip>:3001/ |
Admin interface | Yes — admin password |
http://<lan-ip>:3001/results |
Public results view | No — auto-refreshes every 30s |
http://<lan-ip>:3001/scoring |
Stage scoring login | Yes — stage password |
http://<lan-ip>:3001/squads |
Public squads view | No |
The backend determines the mode from the URL path (getDomainMode() in app.ts) 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 |
| 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 # macOS arm64
npm run build:win # Windows x64
npm run build:linux # Linux x64 AppImage
npm run build:linux-arm64 # Linux arm64 AppImage
# The Linux AppImages use a static runtime and need no zlib1g-dev package.
# Normal AppImage mounting still requires FUSE2 on the target system.
# Linux builds on macOS require Docker Desktop to bundle PostgreSQL's Linux
# shared libraries; start Docker before running them.Important: 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
│ │ └── 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 based on URL path. |
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, 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 | http://<lan-ip>:3001/results or any URL |
View results only, no login needed |
| Stage Officer | http://<lan-ip>:3001/scoring + 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 databases and
.cab match exports:
- A
.cabexport is a Microsoft Cabinet (or ZIP) archive of ADO XML rowsets —THEMATCH.XML,MEMBER.XML,ENROLLED.XML,STAGE.XML,SCORE.XML— holding one match. The parser accepts stored and MSZIP-compressed cabinets. - Unknown WinMSS division IDs produce a warning and fall back to Production instead of failing the import; re-importing the same match updates it.
- 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.
The LAN URL is displayed in the admin header UI for mobile access.
The Android build runs entirely on the device — no computer, no server, no network. It ships the same React frontend inside a Capacitor shell, but with the HTTP/PostgreSQL data client swapped for an on-device SQLite one.
- Shell —
mobile/(Capacitor 8), native project inmobile/android/ - Frontend — the same
frontend/build, pointed at bywebDir - Data — SQLite via
@capacitor-community/sqlite, schema infrontend/src/services/data/sqlite/schema.ts - Shared domain —
packages/core/holds every scoring formula and domain type so the phone, the browser and the desktop backend cannot drift apart
npm install
npm run build:android:debug # assemble a debug APK
npm run open:android # or open in Android StudioRequires JDK 21 and the Android SDK (platform 36). The first build downloads Gradle dependencies.
Step-by-step build and device-install guide (USB, Wi-Fi, release signing):
mobile/README.md.
Matches can be moved between the desktop/web app and a phone as .match.json
(on-device import/export); WinMSS .cab and PractiScore .psc files import
directly on the device, and Settings → Database exports or restores a full
JSON backup of the device data without a computer. Not yet implemented on
Android: WinMSS .mdb/.accdb import, CSV import/export, PSC export, cups, and
stage images. Full details, the PostgreSQL→SQLite mapping and the remaining work
are in docs/android.md.
The iOS build is the same Capacitor shell and the same on-device SQLite data layer as Android, packaged with Swift Package Manager and requiring Xcode 26 or newer.
- Shell —
mobile/(Capacitor 8), native project inmobile/ios/ - Frontend — the same
frontend/build, pointed at bywebDir - Data — SQLite via
@capacitor-community/sqlite, stored inLibrary/CapacitorDatabaseand included in device backups
npm install
npm run build:ios:simulator # build + sync + compile for the simulator
npm run open:ios # open in Xcode, then press RunInstalling on a real iPhone/iPad needs no App Store: a free Apple Account
(Personal Team) is enough, with the provisioning profile re-signed from Xcode
about once a week. A paid Apple Developer account enables Ad Hoc .ipa
distribution for club devices with a one-year lifetime.
Step-by-step build, signing and device-install guide (simulator, free Apple
Account, Ad Hoc, troubleshooting): mobile/README-ios.md.
Feature coverage matches the Android app, including .match.json import/export,
WinMSS .cab and PractiScore .psc import, and full JSON backup/restore
through the Files app. The remaining gaps are WinMSS .mdb/.accdb import, CSV
import/export, PSC export, cups and stage images. See
docs/android.md.
Versions are published by .github/workflows/release.yml.
Pushing a version tag builds everything and creates a GitHub Release with the
changelog and all installers attached — desktop bundles for macOS, Windows (x64 and
arm64) and Linux, plus the signed Android APK:
npm run version:minor
git commit -am "chore: release 2.6.0"
git tag v2.6.0
git push origin main --tagsThe Android job signs the APK with the release keystore held in repository secrets,
so the downloaded IPSCScore.apk installs over previous releases. Setup, secrets,
asset names and troubleshooting: docs/releasing.md.
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. - 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.