What RelayTimer Is
RelayTimer is a React Progressive Web App (PWA) for managing relay races — specifically Hood to Coast (HTC), Reno Tahoe Odyssey (RTO), and similar 36-leg relay events. It runs entirely offline after the initial load. There are no network calls at runtime except for optional CartoDB map tiles.
Van captains and team managers who need real-time pace tracking, ETA forecasting, and runner coordination during a multi-day relay race.
~$20/team SaaS with official race organizer partnerships.
Technology Stack
| Layer | Technology | Version |
|---|---|---|
| Framework | React | 19.x |
| Build tool | Vite | 8.x |
| Maps | Leaflet | 1.9.4 |
| Styling | CSS custom properties + hand-written CSS | — |
| PWA | Custom service worker (sw.js) | — |
| Hosting | Cloudflare Pages (via GitHub push) | — |
| Fonts | Self-hosted WOFF2 (Bebas Neue, Barlow Condensed, Nunito, DM Mono) | — |
Project Structure
Source of Truth (edit here)
C:\Users\garre\OneDrive\HTC_App\ClaudeCode Files\RelayTimer_v1.5\
Vite Project Root (run commands here)
C:\Users\garre\OneDrive\HTC_App\relay-timer\
Workflow
RelayTimer_v1.5/relay-timer/src/npm run build/deployHTC → CloudflareScreens & UI
The app has 6 tabs (accessible via bottom navigation bar) plus a Start Screen (shown before race begins) and a Race Complete screen (shown when all 36 legs are finished).
🏁 Start Screen
Race banner with stats, time-of-day drum picker to set race start, and "Start Race" button. Shown when raceStartSec === null.
🏠 Home Screen
Race progress bars, 4-card status grid (pace/finish/elapsed/legs), and "On Course" + "Up Next" runner cards with elevation and countdown.
🔄 Exchange Screen
Primary race-day screen. Runner In button, countdown card, elevation profile, Up Next queue, race position strip, and van navigation row.
📋 Timeline Screen
Vertical 36-leg timeline with spine dots, delta chips, color-coded completion status, and per-leg reassign buttons. Auto-scrolls to active leg.
👟 Runners Screen
Card per runner showing miles, base pace, leg chips (tappable), and cumulative stats for completed legs.
📍 GPS Screen
Real-time pace observation via device GPS + GPX route snapping. Two-stage commit, observed pace adjuster (±30s/mi), inline Leaflet map.
⚙️ Settings Screen
Dev tools (sim clock, fatigue model, offline maps), appearance, race selector, start time editor, export, checkpoints, and reset.
Exchange Screen — Detailed
The Exchange tab is the primary screen van captains use during racing. Key UI elements:
Large prominent button that logs a runner finishing a leg and auto-starts the next. Gently pulses while waiting (2.5s animation, 8px box-shadow). Flashes green on tap, turns red/urgent when overdue. Shows a 5-minute undo bar after each tap with confirmation before undoing.
Large timer with progress bar, estimated finish, miles done/remaining, current time. Shows "+M:SS" when overdue.
Flex row with up to 3 buttons: Van Directions (Google Maps with exchange coords + van waypoints), Offline Directions (text directions), and Leg Map PDF (official race leg map).
Navigation Bar (always visible)
| Position | Content |
|---|---|
| Left | Race short name + legs completed/total (e.g. "HTC · 5/36"), current runner + leg number |
| Center | Remaining countdown (M:SS) for active leg |
| Right | Status pill (ahead/behind/on pace/overdue), projected finish time |
| Top (sim) | Purple banner with speed and simulated time of day when sim mode is active |
| Top (update) | Green toast when service worker update is detected |
Modals & Overlays
LegDetailModal
Triggered from Timeline, Runners (tap leg chip), or Exchange (tap Up Next queue). Contains leg minimap (read-only Leaflet), stats rows, elevation profile, pace source, delta vs forecast, start/finish time editors (drum pickers), Google Maps + Offline Directions buttons, and inline offline directions in a nested modal overlay.
AdjustPaceModal
Drum picker for min:sec pace override in the range 4:00–20:59/mi. Shows runner info, base pace, fatigue %, and live preview of projected finish time.
ReassignModal
Runner list with miles done/total and pace. Includes "+ Add new runner" option with name input (creates ad-hoc runner at 9:00/mi default). Reassigning clears any pace override or GPS observation for that leg.
Confirmation Dialogs
| Dialog | Message / Content |
|---|---|
| Undo Runner In | "Remove the logged finish for Leg N (Runner)?" |
| Reset All Actuals | "Clear all logged times and return to pre-start state. Cannot be undone." |
| Revert to Checkpoint | Diff table: checkpoint time, legs in checkpoint, current legs, actuals that would be lost. Pre-revert checkpoint auto-saved before reverting. |
| Switch Race | "Switching to [race name] will clear all current race data." |
Data Flow & State Management
Architecture
All state lives in useRaceState.js using useState/useMemo/useCallback — no Redux, no external stores. The hook returns all state values, derived data, and handlers to the App shell, which passes them as props to screen components. Screen components are wrapped in React.memo; all handlers use useCallback for stable references.
State Shape
// Core race state (persisted to localStorage)
raceStartSec // number | null — seconds since midnight
actuals // { [legNumber]: { startSec?, finishSec? } }
legPaceOverrides // { [legNumber]: secondsPerMile }
observedPaces // { [legNumber]: { distanceMiles, rawSecPerMile, adjustedSecPerMile,
// adjustmentSec, elapsedAtObservation, observedAtSec, capturedAt,
// gpsAccuracyMeters, snapErrorFeet } }
runnerOverrides // { [legNumber]: { runnerId } | { runnerName } }
paceModel // { elevation_gain_penalty_sec_per_100ft, elevation_loss_benefit_sec_per_100ft,
// fatigue_k, fatigue_L }
// UI state (not persisted)
nowSec // number — current time in seconds since midnight (ticks every 1s)
tab // "home" | "exchange" | "timeline" | "runners" | "gps" | "settings"
colorMode // "auto" | "light" | "dark" (persisted separately)
selectedRaceId // "htc_2026" | "rto_2026" (persisted separately)
lastLoggedLeg // forecast object | null (for undo)
undoVisible // boolean (auto-clears after 5 minutes)
swUpdated // boolean (service worker update detected)
// Simulation state
simEnabled // boolean
simSpeed // 1 | 10 | 60 | 120 | 240
simNowSec // number | null
simPaused // boolean
effectiveNow // derived: simEnabled ? simNowSec : nowSec
// Derived state (via useMemo)
runners // Runner array with runnerOverrides applied
forecast // 36-element forecast array from buildForecast
teamStatus // Team status object from getTeamStatus
Forecast Pipeline
Each forecast entry includes status ("complete" | "active" | "upcoming"), status badge, estimated and actual start/finish/duration/pace, delta, pace source, fatigue multiplier, cumulative miles, and GPS observation fields when applicable.
Completed legs lock: When a leg has actual.finishSec, its actual finish becomes the cascade anchor for the next leg's start.
Active leg with GPS observation: Uses split-time projection — startSec + elapsedAtObservation + (remainingMiles × effectivePace) — instead of the standard pace × distance formula.
Pace Priority Chain
MIN_PACE_SEC_PER_MILE (240 = 4:00/mi), applied at the source in handlers and again in getEffectivePace.Fatigue Model
Exponential decay per runner based on cumulative miles run before each leg:
multiplier = 1 + k × (1 − e^(−cumulativeMiles / L))
| Parameter | Default | Meaning |
|---|---|---|
| k | 0.18 | 18% max pace degradation at asymptote |
| L | 45 | 63% of max degradation reached at 45 miles |
A runner on their first leg always gets multiplier = 1.00. Tunable in Settings → Developer Tools.
Elevation Adjustment
adjustedPace = basePace + (gain/dist/100) × gainPenalty − (loss/dist/100) × lossBenefit
Defaults: +8 sec/mi per 100ft gain, −3 sec/mi per 100ft loss. Applied before fatigue multiplier. Floor of 60 sec/mi on the result.
Persistence
| Key | Purpose |
|---|---|
relayTimer_${raceId}_v1 | Full state — saved on every state change |
relayTimer_cp_0/1/2 | Rolling checkpoint slots — written on every Runner In |
relayTimer_cp_index | Round-robin index for checkpoint slots |
relay_color_mode | Color mode preference (auto/light/dark) |
relay_selected_race | Currently selected race ID |
relayTimer_htc2026_v4 | Legacy key — auto-migrated on first load |
Time Handling
All times stored as seconds since midnight (not Unix timestamps). monotonicSec(prev, raceStart) handles midnight rollover by adding 86400 when raw time is less than previous by more than 6 hours. Overnight race start times and actual duration calculations also handle rollover.
Service Worker
Cache version: v2. Four caches:
| Cache | Strategy | Contents |
|---|---|---|
app-shell-v2 | Stale-while-revalidate | /, /index.html, all same-origin JS/CSS |
fonts-v2 | Cache-first (precached on install) | 8 WOFF2 files + fonts.css |
gpx-routes-v2 | Cache-first | GPX files (cached on first access) |
carto-tiles-v2 | Cache-first | CartoDB map tiles |
Lifecycle
Install: Precaches app shell + all fonts, then calls skipWaiting().
Activate: Purges old caches (any not ending with current version), then claims clients.
Message Handlers
SKIP_WAITING → calls self.skipWaiting()
CACHE_TILES → bulk-downloads tile URLs, reports progress via CACHE_PROGRESS/CACHE_DONE
Update Detection
main.jsx listens for controllerchange (gated on hadController to skip first install). Dispatches sw-updated custom event. App shows green toast: "Update available — tap to reload."
GPS System
Route Snapping (gpxUtils.js)
| Function | Purpose |
|---|---|
parseGpx(gpxString) | Parses GPX XML into [{lat, lon, distFromStart}] with cumulative haversine distances (miles) |
snapToRoute(lat, lon, gpxPath) | Projects user position onto nearest GPX segment via perpendicular projection. Returns {distanceMiles, snapErrorMiles, snapErrorFeet} |
calcPace(elapsedSec, distMiles) | Returns {paceStr, mph, secPerMile} |
haversine(a, b) | Great-circle distance in miles |
haversineMeters(a, b) | Great-circle distance in meters (for LiveMapScreen) |
parseGpxMeters(gpxString) | Parses into [{lat, lng, cumDist}] with cumulative meters |
getTileUrlsForBounds(bounds, minZ, maxZ) | Generates CartoDB tile URLs for offline caching |
Live Map (LiveMapScreen.jsx)
Two rendering modes: Inline (320px/400px, embedded in GPS scroll view) and Full-screen (fills available space with panel at bottom).
Exchange proximity regime changes route and dot colors based on remaining distance: green (>1 mi) → yellow (<1 mi) → red (<0.3 mi). Badge shows proximity when regime is active.
Observation Flow
getCurrentPosition (high accuracy, 10s timeout)pendingObs — not yet committed to forecasthandleObservationCapture clamps to 4:00/mi floor, writes to observedPaces + legPaceOverridesobservedPaces and legPaceOverrides, resets GPS lock stateRace Data Format
Each race JSON file follows schema v1.1.0. Supported races: htc_2026 and rto_2026. Selectable via Settings dropdown via RACE_REGISTRY.
{
"_meta": { "schema_version": "1.1.0" },
"race": {
"id": "htc_2026",
"name": "Hood to Coast",
"short_name": "HTC",
"total_legs": 36,
"total_distance_miles": 196.7,
"start_time": "2026-08-28T09:00:00",
"accent_color": "#D64F1E",
"banner_gradient": ["#2D4A35", "#1A6B3C", "#4A7C59"],
"leg_map_pdf_base": "https://hoodtocoast.com/...HTC-Leg-{N}.pdf"
},
"pace_model": {
"elevation_gain_penalty_sec_per_100ft": 8,
"elevation_loss_benefit_sec_per_100ft": 3,
"fatigue_k": 0.18,
"fatigue_L": 45
},
"difficulty_reference": {
"scale": {
"1": { "label": "E", "display": "Easy" },
"2": { "label": "M", "display": "Moderate" },
"4": { "label": "MC", "display": "Moderate-Hard" },
"6": { "label": "MD", "display": "Hard" }
}
},
"legs": [
{
"leg_number": 1,
"distance_miles": 6.26,
"difficulty_rating": 6,
"difficulty_label": "MD",
"elevation_gain_ft": 0,
"elevation_loss_ft": 2036,
"time_of_day": "day",
"exchange_zone": "Exchange 1",
"exchange_lat": 45.304771,
"exchange_lng": -121.759188,
"van_waypoints": [{ "lat": 45.3, "lng": -121.7 }],
"van_directions_offline": "Turn-by-turn text...",
"image_url": "https://..."
}
],
"runners": [
{
"id": "htc_v1_1",
"name": "Runner 1",
"relay_pace_sec_per_mile": 540,
"legs_assigned": [1, 7, 13, 19, 25, 31]
}
]
}
CSS & Theming
Design System
| Role | Font |
|---|---|
| Display / Headers | Bebas Neue |
| Body text | Nunito |
| Condensed labels | Barlow Condensed |
| Monospace / Data | DM Mono |
Color palette: Mt. Hood / Pacific Northwest inspired. Light mode: forest greens (#2D4A35, #1A6B3C), meadow whites, earth accents (#D64F1E). Dark mode: deep navy (#0D1117, #131A24), bright greens (#27C97A), electric accents (#FF5C00).
Border radius: --r: 16px (generous rounding). Shadows: 3-tier system (sm, md, lg) with green-tinted shadows in light mode.
Color Mode
Three options via data-color-mode attribute on root <div>: auto (follows system), light, dark. Stored in localStorage as relay_color_mode.
Key CSS Classes
| Class | Purpose |
|---|---|
.runner-in-btn.pulse | Gentle pulsing animation (2.5s period, 8px box-shadow, 0.2 opacity) |
.runner-in-btn.overdue | Red urgent state |
.elev-info-row | Transparent row above elevation SVG on Exchange screen |
.elev-info-note | Right-aligned note text within elevation info row |
.home-runner-detail | Single-line truncation with nowrap + ellipsis |
.pace-source-badge | Color-coded source indicator (EST/MAN/OBS/ADJ) |
.exchange-overdue / .exchange-red / etc. | Exchange tab dynamic coloring |
Key Components
DrumColumn / TimeOfDayDrum
iOS-style scroll picker with momentum snapping. ITEM_H=44px, debounced scroll handler (80ms). Used for race start time, leg actual times, and pace override. TimeOfDayDrum uses 12-hour picker with AM/PM toggle.
ElevationProfile
SVG chart rendering pre-computed elevation data from legElevationProfiles.js. ViewBox: 400×96. Animated runner dot (opacity pulse: 1→0.15→1 at 1.2s). Gradient fill (green, 22%→4% opacity).
Two display modes: Default (elevation badge overlay in top corner) and hideBadge (info row above SVG with elevation left-aligned and notes right-aligned — used on Exchange screen).
CountdownCard
Large countdown timer with progress bar. Turns red/overdue showing "+M:SS" when estimated finish time passes. 2×2 grid: finish time, estimated miles done, current time, remaining miles.
RacePositionStrip
36 tiny responsive blocks for all legs. Colors: complete=green, active=pulsing accent, overdue=red pulse, upcoming=gray.
LegMiniMap
Read-only Leaflet map (all interaction disabled: no dragging, zoom, keyboard, tap). Green start dot, red end dot, green route polyline. 180px height, auto-fit bounds with 16px padding.
NavStatusPill
Colored badge in nav bar right section. States: neutral, overdue, ahead-strong (<−120s), ahead (<0s), on-pace (≤120s), behind (>120s).
DiffBadge / DeltaChip
DiffBadge: difficulty label with color class (E/M/MC/MD). DeltaChip: ▲/▼ duration with color class (ahead-strong/ahead/on-pace/behind).
Export Formats
CSV Export (27 columns)
Filename: relay_timer_{raceId}_{YYYYMMDD_HHMM}.csv
| Column Group | Fields |
|---|---|
| Leg info | Leg, Runner, Distance, Elev Gain/Loss |
| Model | Elev Adj Factor, Fatigue Multiplier, Combined Adj Factor |
| Estimates | Base Pace, Est Pace, Pace Source, Est Duration, Est Start, Est Finish |
| Actuals | Actual Pace, Actual Duration, Actual Start, Actual Finish |
| Deltas | Delta (sec + MM:SS), Cumulative Delta (sec + MM:SS) |
JSON Export
Filename: relay_timer_{raceId}_{YYYYMMDD_HHMM}.json. Complete state snapshot including raceStartSec, actuals, legPaceOverrides, observedPaces, runnerOverrides, and paceModel. Re-importable via "Load State from File" (auto-checkpoints current state before loading).
Build & Deploy
Development
cd C:\Users\garre\OneDrive\HTC_App\relay-timer
npm run dev # Vite dev server at http://localhost:5173
Production Build
npm run build # Output to relay-timer/dist/
Build output: ~369 KB JS (main) + ~162 KB (Leaflet chunk, lazy-loaded) + ~39 KB CSS
Deploy
Use /deployHTC skill which: (1) verifies version consistency between raceUtils.js and CLAUDE.md, (2) commits all changes, (3) pushes to GitHub, (4) Cloudflare Pages auto-deploys from GitHub.
Tests
node src/utils/forecastEngine.js # 81 assertions covering:
| Test Group | Coverage |
|---|---|
| Formatting | formatDuration, formatPace, parsePace, formatTimeOfDay, parseTimeOfDay |
| Elevation adjustment | flat, climb, loss, net, zero-distance |
| Fatigue model | fresh, progressive, asymptote, estimateLegDuration |
| Pace priority chain | EST, MAN, OBS, ADJ |
| Pace floor clamp | MIN_PACE_SEC_PER_MILE = 240 |
| Forecast | 3-leg race, cascading, badges, overrides, GPS obs, midnight rollover, zero-distance |
| Actuals management | logRunnerIn, logRunnerOut, editActual, undoRunnerIn |
| Team status | completed count, delta, overdue, summary |
| Runner summaries | legs, miles, pace, delta |
test/GpxTrackerTestHarness.jsx has T1–T10 covering parse, haversine, snap, and pace calculations.
Simulation Mode
Developer-only feature under Settings → Developer Tools.
1×, 10×, 60×, 120×, 240× — increments simNowSec by speed value every real second. Purple banner at top shows sim status and simulated time of day.
All race logic (forecast, countdown, overdue detection) uses simulated time via effectiveNow. Sim clock starts at race start time and resets on toggle. Pause/Resume control available.
Handler Reference
All handlers defined in useRaceState.js with useCallback:
| Handler | Purpose |
|---|---|
| handleSetStart(sec) | Set race start time, start leg 1, switch to home tab |
| handleRunnerIn(legNumber) | Log finish for current leg, auto-start next, clear overrides, write checkpoint, show undo |
| handleUndo() | Remove last Runner In finish + associated next-leg start |
| handleReassign(n, runnerId, runnerName) | Reassign a leg to different or new runner, clear overrides |
| handleSaveActual(n, startSec, finishSec) | Manually edit a leg's start/finish times |
| handleRemoveActual(n) | Remove a leg's actual times |
| handleSetPaceOverride(n, sec) | Set manual pace override for a leg |
| handleClearPaceOverride(n) | Remove manual pace override |
| handleObservationCapture(n, obsData) | Apply GPS observation (clamped to 4:00/mi floor) |
| handlePushObservedPace(n, adjPace, adjSec) | Update adjusted pace on existing observation |
| handleClearObservation(n) | Remove GPS observation + associated pace override |
| handleResetRace() | Clear all state, remove checkpoint and save data |
| handleExportCSV() | Generate and download CSV file |
| handleSaveJSON() | Generate and download JSON state file |
| handleLoadJSON(file) | Load state from JSON file (checkpoints current state first) |
| handleRevertToCheckpoint(cp) | Restore state from a checkpoint |
| handleRaceSwitch(newId) | Switch to different race (with confirmation if race in progress) |
| handleSetColorMode(mode) | Set color mode and persist |
| handleToggleSim() | Toggle simulation mode |
| handleToggleSimPause() | Toggle simulation pause |
File-by-File Reference
| File | Size | Description |
|---|---|---|
RelayTimer.jsx | ~115 lines | App shell: TABS array, pre-race/race render, nav bar, race-switch dialog |
hooks/useRaceState.js | ~253 lines | All useState, buildRunners(), clock effects, persistence, derived state, all handlers |
utils/forecastEngine.js | ~568 lines | Pure math: constants, formatting, pace chain, buildForecast, getTeamStatus, getRunnerSummaries, actuals, 81 tests |
utils/raceUtils.js | ~183 lines | APP_VERSION ("1.8.1"), RACE_REGISTRY, RACE_LIST, TOD_ICONS, SIM_SPEEDS, URL builders, storage, CSV export |
utils/gpxUtils.js | ~111 lines | haversine, parseGpx, projectPointOnSegment, snapToRoute, getTileUrlsForBounds, calcPace |
screens/HomeScreen.jsx | ~86 lines | Progress bars, status grid, runner cards |
screens/ExchangeScreen.jsx | ~100 lines | Runner In button, pace button, van nav row, undo bar, position strip, countdown, elevation, Up Next queue, race complete |
screens/TimelineScreen.jsx | ~43 lines | Vertical timeline, auto-scroll, spine dots, delta chips, reassign buttons |
screens/RunnersScreen.jsx | ~26 lines | Runner cards, leg chips, cumulative stats |
screens/GpxTrackerScreen.jsx | ~243 lines | GPX loading, GPS flow, results card, quality indicators, two-stage commit, ObservedPaceAdjuster, inline map |
screens/SettingsScreen.jsx | ~195 lines | Version badge, dev tools, offline maps, save/export, checkpoints, appearance, race selector, reset |
screens/LiveMapScreen.jsx | ~473 lines | Leaflet init, GPX route rendering, expected position interpolation, GPS watch, tile download |
components/components.jsx | ~217 lines | DrumColumn, TimeOfDayDrum, ElevationProfile, LegMiniMap, NavStatusPill, DiffBadge, DeltaChip, RacePositionStrip, CountdownCard |
components/LegDetailModal.jsx | ~86 lines | Full leg detail sheet with map, stats, time editors |
components/ReassignModal.jsx | ~39 lines | Runner list, add-new option |
components/AdjustPaceModal.jsx | ~31 lines | Pace drum picker (4:00–20:59/mi range) |
components/ObservedPaceAdjuster.jsx | ~69 lines | ±30s/mi buttons, projected finish preview, zero-distance error state |
components/Icons.jsx | ~12 lines | 12 SVG icon components |
data/legElevationProfiles.js | ~64 KB | Pre-computed [distance, elevation] arrays per leg number |
data/races/htc_2026.json | — | HTC race metadata, 36 legs, exchange coords, van directions |
data/races/rto_2026.json | — | RTO race metadata, 36 legs |
Version History
RelayTimer_v1.5/, provides everything needed to rebuild RelayTimer v1.8.1 from scratch.