GlideComp scores hanggliding and paragliding competitions — and analyses the whole field's flights, so pilots can learn from the people they flew the day with.
What it does:
- Pilots (or competition organizers) load IGC track log files and XCTask task files in the browser
- The app analyzes flights: task completion, scoring explanations, glide performance, thermal/climb analysis
- Full CIVL GAP scoring implementation (FAI Sporting Code Section 7F) — distance, time, leading, and arrival points for both PG and HG
- Field analysis — every track on a task measured against every other one across climbing, gliding, decision-making, gaggle and race craft, ranked by how strongly each behaviour separated the field, and read against the day's weather
- Includes 2D (Mapbox) and 3D (Three.js globe) map visualization of flight tracks
- Competition management features: pilot registration, task setup, scoring, penalties, with a full audit log for transparency
How it's built:
- Client-side first — IGC parsing and flight analysis happen entirely in the browser, no server required for core functionality
- Frontend: React SPA hosted on Cloudflare Pages, built with react-aria-components (the app's only component kit — see docs/2026-07-18-rac-adoption-guide.md) and Tailwind CSS. The public competition pages are server-rendered by Pages Functions, and the content pages (home, about, scoring guides) are prerendered static HTML
- Backend: Cloudflare Workers API for competition management, with D1 (SQLite) database and R2 storage
- Engine: A dedicated
@glidecomp/enginepackage handles geo calculations (WGS84 ellipsoid math, Vincenty formulas) and flight analysis
Key design principles:
- No server-side storage needed for basic use — drag-and-drop files and analyze locally
- All scoring decisions are explainable and auditable
- Every mutation affecting competition scores is audit-logged
The app is live at glidecomp.com.
- Flight event detection — automatic detection of takeoff, landing, thermals, glides, start/goal crossings, and turnpoint tagging
- Thermal analysis — entry/exit times, altitude gain, average climb rate
- Glide analysis — distance, altitude lost, L/D ratio, plus sink detection for poor glides
- GAP scoring — CIVL GAP scoring with distance, time, leading, and arrival points, including stopped tasks (S7F §12.3)
- Open-distance scoring — for tasks flown downwind from a launch cylinder rather than around a route
- Report card — a per-pilot page explaining exactly how their score was arrived at, with the substituted arithmetic, each component's scoring curve drawn with the field on it, and links into the scoring guides
- Field analysis — 26 cross-pilot behavioural metrics per task, ranked by Spearman correlation against finishing position, with per-comp consistency across tasks
- Task weather — modelled wind, climb and timing for the task's day from an outside provider, plus the organizer's own weather notes, read against what the field actually flew
- Track quality checks — every tracklog assessed against its task (FAI S7A §4.4.2); hard findings withhold a track from scoring, and every verdict is overridable by the organizer
- Competition management — create competitions, register pilots, upload IGC tracks, manage tasks, apply penalties, with full audit logging
- Scores export — the full standings as a pivot-ready CSV, downloadable or opened live in Google Sheets
- Waypoints — a per-competition waypoint list, editable and exportable to flight instruments
- Authentication — sign in with Google, or passwordless email one-time codes
- Task editor — create and edit tasks with drag-to-reorder turnpoints, waypoint database search, and click-on-map placement
- Multiple data sources — drag & drop IGC/XCTSK files, import from XContest by task code, or import from AirScore by URL
- Interactive map — 2D (Mapbox) and 3D views with track overlay, task cylinders, and map annotations
- 3D flight replay — a Three.js replay of a whole task's field, with gaggle detection
- Altitude sparkline — clickable time-series chart linked to events and map position
- Configurable units & detection — speed, altitude, distance, climb rate units; adjustable thermal/glide detection thresholds
- Bun (also requires Node.js 20+)
- A MapBox access token (for the map). If your token has URL restrictions, ensure
glidecomp.comandlocalhost:3000are in the allowed URLs list.
bun install
# Copy .env.example and add your MapBox token
cp .env.example .env
# Edit .env and set VITE_MAPBOX_TOKEN=your_token_hereStart the frontend and all API workers together:
bun run devThis starts two processes:
- Frontend — Vite dev server at http://localhost:3000
- API Workers — auth-api, competition-api and airscore-api, all in ONE
wrangler devsession, reached through thedev-routerWorker at http://localhost:8790
Every worker shares a single Miniflare instance, so the D1 database has one
writer — running them as separate processes on separate ports raced on the
shared SQLite file (issue #477). Only the router's port is exposed; it
dispatches /api/auth/*, /api/comp/*, /api/airscore/* and friends to the
right worker over service bindings, exactly as the Pages Functions do in
production. Vite proxies all of /api there, so the browser only ever talks to
:3000. To start the workers on their own: bun run dev:workers.
The auth worker requires a .dev.vars file in web/workers/auth-api/ — see docs/auth.md for setup.
To start only the frontend (without any workers):
bun run dev:frontendAirScore features are served by the same router; nothing extra to start. If you'd rather use the production AirScore API instead of the local worker:
VITE_AIRSCORE_URL=https://glidecomp.com/api/airscore bun run devbun run preview builds the site and serves it through wrangler pages dev, so
you exercise the SSR Functions, the sitemap and the Astro pages exactly as
production does — no HMR, so a code change needs a re-run.
bun run preview # http://localhost:3000bun run preview binds ports on your Mac (pages dev, the Workers' dev-router,
a workerd inspector) and keeps D1 + R2 in web/.wrangler/state, so a second copy
collides with the first. To run one that can't, put it in a container — the
stock ports stay inside the VM and only one is published:
bun run preview:container # http://localhost:3200
PORT=3201 bun run preview:container # a second, wholly independent instanceRequires Apple's container (macOS 26+,
Apple silicon): brew install container. Each PORT gets its own named volume
for D1 + R2, so instances never see each other's data — or your host checkout's.
Your checkout is bind-mounted read-only and staged into the container on
start, so a source edit needs a restart (Ctrl-C, re-run), not an image rebuild.
There's no HMR — preview serves a production build, same as on the host. Use
bun run dev for a fast edit loop.
The image holds only the dependency tree, and the script rebuilds it for you
when it needs to: it hashes the files that layer is built from (bun.lock, the
workspace manifests, patches/) and compares against a stamp beside the image,
printing the reason when it rebuilds. Editing source never triggers one. Even a
missed rebuild is only ever slower, not wrong — the entrypoint runs
bun install --frozen-lockfile after staging, which reconciles node_modules
to whatever lockfile the mount brought in.
| variable | default | when you need it |
|---|---|---|
PORT |
3200 |
run more than one instance |
SKIP_SEED |
0 |
keep an instance's existing data instead of reseeding |
REBUILD |
0 |
force an image rebuild — normally detected for you |
CPUS / MEMORY |
6 / 8G |
vite build is OOM-killed below ~4G |
The default port is 3200 because 3000 (vite), 3100 (SSR e2e), 4321 (astro),
8790 (the Workers' dev-router) and 9232 (its inspector) are all already spoken
for — and
because a native bun run dev binds Vite to [::1]:3000 while a published
container port binds 127.0.0.1:3000. macOS treats those as different sockets,
so both bind happily and localhost quietly serves you whichever one the
resolver picked. The script refuses a port anything already holds, on either
stack, rather than let that happen.
The browser only ever talks to the published port. The one thing that doesn't
work in the container is the analysis page's AirScore import — it calls
same-origin /api/airscore, and there's no Pages Function proxying that, so it
404s here exactly as it does in production.
bun run test # bun test sweep (root, engine, airscore-api, dev-router, scripts) + type check
bun run test:all # the above, then the three vitest suites concurrently
bun run test:comp # Competition API tests only
bun run test:auth # Auth API tests only
bun run test:frontend # Frontend tests only
bun run test:e2e # Playwright end-to-end tests
bun run test:e2e:ssr # Playwright suite against the server-rendered build
bun run db:migrate # Apply D1 migrations to the local state
bun run typecheck # Type check the root project
bun run typecheck:all # Type check everything (frontend + engine + all four workers)The e2e suite writes to the persistent local D1 state in web/.wrangler/state.
Specs clean up after themselves and each run sweeps anything a killed run left
behind, so this should stay tidy on its own — but if local e2e results ever look
inexplicable, bun run kill-state resets the database to empty (then
bun run seed to put the sample comps back).
Read https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session
- Push to
master→ deploys to production - Push to other branches → deploys to preview URL
bun run deploy # Manual deploy to Cloudflare Pages
bun run deploy:worker # Manual deploy AirScore API Worker
bun run deploy:comp # Manual deploy Competition API Worker
bun run deploy:auth # Manual deploy Auth API Worker
bun run deploy:all # Deploy Pages + AirScore & Competition Workers (auth-api is separate: deploy:auth)URLs:
- Production: https://glidecomp.com
- Previews: https://{branch}.glidecomp.pages.dev
detect-events - Detect flight events from an IGC file, outputting CSV:
bun run detect-events -- <flight.igc> [task.xctsk]
# Example with sample data
bun run detect-events -- \
web/frontend/public/data/tracks/2025-01-05-Tushar-Corryong.igc \
web/frontend/public/data/tasks/buje.xctskget-xcontest-task - Download a task from XContest by code:
bun run get-xcontest-task -- face
bun run get-xcontest-task -- --file task.jsonscore-task - Score multiple pilots against a task using CIVL GAP (FAI Sporting Code Section 7F):
bun run score-task -- <task.xctsk> <igc-file-or-folder>... [options]
# Scores identically to the web app. --wing (HG or PG) is REQUIRED for GAP
# scoring — the CLI has no comp record and won't guess. Given it, the run starts
# from the official per-category FAI/S7F defaults and each flag overrides one
# parameter. Flag names are the kebab-case of the gap_params keys the UI saves;
# units are the engine's (metres / seconds / 0-1 ratios). Grouped by function:
#
# Wing (required for GAP):
# --wing <HG|PG> Competition wing (`scoring`)
# Task mode:
# --open-distance Score as open distance (GAP options ignored)
# --comp <slug-or-dir> Score a whole bundled comp (every task per class,
# plus a per-class cross-task aggregate); wing comes
# from the comp.json manifest. Implies --field-analysis.
# Field analysis:
# --field-analysis After the scores, print the behavioural field
# analysis — per-pilot metrics (climbing, gliding,
# decisions, gaggle, race craft, day profile/wind)
# led by the metric-separation ranking (Spearman ρ
# vs GAP rank). See docs/2026-07-18-field-analysis-plan.md.
# Nominal parameters:
# --nominal-distance <m> `nominalDistance` (default: 70% of task distance)
# --nominal-distance-pct <%> That percentage, when you'd rather not do the sum
# --nominal-goal <ratio> `nominalGoal` 0-1 (default: 0.3)
# --nominal-time <s> `nominalTime` in seconds (default: 5400)
# --minimum-distance <m> `minimumDistance` in metres (default: 5000)
# Scoring terms (per-wing defaults; each has a --use-… form to turn it back on):
# --no-use-leading Disable leading (departure) points (`useLeading`)
# --no-use-arrival Disable arrival points (`useArrival`)
# --no-use-distance-difficulty Disable difficulty points (`useDistanceDifficulty`)
# Stopped tasks (S7F §12.3):
# --stop-time <iso-datetime> Announce a stop, e.g. 2026-01-15T03:45:00Z
# --score-back-time <s> PG score-back window (default: 300)
# Formula & advanced:
# --leading-formula <weighted|classic> `leadingFormula` (default: classic HG / weighted PG)
# --leading-weight-formula <gap2020|s7f2024> PG leading weight (default: gap2020)
# --time-points-exponent <5/6|2/3> `timePointsExponent` (default: 5/6)
# --ess-not-goal-factor <ratio> `essNotGoalFactor` (default: 0.8 HG / 0 PG)
# (see --help for the full list, incl. nominal-launch, distance-origin,
# jump-the-gun-factor, jump-the-gun-max-seconds, leading-time-ratio)
# Output:
# --json Output as JSON
# Example: score Corryong Cup 2026 Open Task 1 as HG with the official defaults
bun run score-task \
web/samples/comps/corryong-cup-2026-open-t1/task.xctsk \
web/samples/comps/corryong-cup-2026-open-t1/ \
--wing HG
# Example: score the whole bundled comp, with field analysis per task and a
# per-class cross-task aggregate
bun run score-task -- --comp corryong-cup-2026Example output:
=== Task Scoring Results (CIVL GAP) ===
Scoring config:
Sport: HG
Leading: on (classic)
Time exponent: 5/6
Arrival: on
Difficulty: on
Nominal: dist 55.2 km / time 90 min / goal 30% / launch 96%
Task distance: 78.8 km
Pilots: 32 flying / 32 present
In goal: 12 (37.5%)
Best time: 1:37:55
Task Validity: 100.0%
Available Points: 1000 (dist: 486, time: 360, lead: 90, arr: 64)
# Pilot Dist SS Time Dist Pts Diff Pts Time Pts Lead Pts Arr Pts Total
-----------------------------------------------------------------------------------------------------------------------
1 Rohan Holtkamp 78.8 km 1:47:55 485.6 242.8 294.1 90.0 64.3 934
2 Jon Durand 78.8 km 1:37:55 485.6 242.8 360.1 21.2 44.2 911
3 Peter Burkitt 78.8 km 1:50:13 485.6 242.8 281.7 89.4 53.4 910
...
13 Rich Reinauer 76.9 km LO 479.7 242.8 0.0 0.0 0.0 480
The full HG table also shows a leading-coefficient (
LC) column; it's elided here for width.
seed - Seed the bundled sample competitions into local D1 + R2 (idempotent; --remote targets production):
bun run seed # every bundled comp
bun run seed big-chip kosci-loop # just these slugscivl-rankings - Import the FAI/CIVL monthly world pilot rankings into D1 (all ten disciplines; idempotent, and a no-op on a day CIVL hasn't published anything new). Runs daily in CI — see docs/civl-rankings.md:
bun run civl-rankingsbuild-3dvis - Pack a task's tracks into the asset the 3D replay loads:
bun run build-3dvisbench-task / bench-analysis - Benchmark the scoring and analysis paths (see the benchmark-engine skill).
functions/ - Cloudflare Pages Functions (SSR for the public /comp pages,
/api/* proxies to the Workers, sitemap, middleware)
web/
frontend/ - Cloudflare Pages frontend (Vite + TypeScript)
static/ - Astro app: the prerendered content pages (home, about,
legal, the scoring guides)
engine/ - Shared analysis library (IGC parsing, event detection, GAP scoring,
field analysis, track quality, weather)
cli/ - CLI utilities (detect-events, get-xcontest-task, score-task, build-3dvis, benchmarks)
workers/
auth-api/ - Authentication API (Cloudflare Worker + D1)
competition-api/ - Competition management API (Cloudflare Worker + D1 + R2)
airscore-api/ - AirScore caching proxy (Cloudflare Worker)
dev-router/ - Dev-only router: owns :8790 and dispatches to the three above
db/
migrations/ - D1 schema migrations (shared by auth-api and competition-api)
samples/ - Bundled sample competitions, CIVL ranking fixtures, reference data
scripts/ - Operational scripts (comp seeding/download/generation, dev helpers, secrets)
e2e/ - Playwright end-to-end tests
docs/ - Feature and architecture specifications
patches/ - Patched dependencies (applied by bun)