Complete Application Documentation

RelayTimer v1.8.1

A React PWA for van captains managing Hood to Coast, RTO, and 36-leg relay races. Real-time pace tracking, ETA forecasting, and runner coordination — fully offline after first load.

React 19 + Vite 8
Offline-first PWA
HTC 2026 & RTO 2026
81 forecast engine tests
Cloudflare Pages
1

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.

🎯 Target Users

Van captains and team managers who need real-time pace tracking, ETA forecasting, and runner coordination during a multi-day relay race.

💼 Business Model

~$20/team SaaS with official race organizer partnerships.

2

Technology Stack

LayerTechnologyVersion
FrameworkReact19.x
Build toolVite8.x
MapsLeaflet1.9.4
StylingCSS custom properties + hand-written CSS
PWACustom service worker (sw.js)
HostingCloudflare Pages (via GitHub push)
FontsSelf-hosted WOFF2 (Bebas Neue, Barlow Condensed, Nunito, DM Mono)
No other runtime dependencies. No Redux, no state management library, no analytics, no external API calls.
3

Project Structure

Source of Truth (edit here)

C:\Users\garre\OneDrive\HTC_App\ClaudeCode Files\RelayTimer_v1.5\

RelayTimer_v1.5/ ├── RelayTimer.jsx # Slim App shell: nav bar, tab routing, race-switch dialog ├── main.jsx # Vite entry: React root + service worker registration ├── relayTimer.css # All CSS: light/dark themes, all component styles ├── screens/ │ ├── HomeScreen.jsx # Race progress, status grid, runner cards │ ├── ExchangeScreen.jsx # Runner In, pace, countdown, elevation │ ├── TimelineScreen.jsx # 36-leg vertical timeline with reassign │ ├── RunnersScreen.jsx # Runner summaries, leg chips, deltas │ ├── GpxTrackerScreen.jsx # GPS position lock, pace check, inline map │ ├── SettingsScreen.jsx # Dev tools, appearance, data management │ ├── StartScreen.jsx # Pre-race start screen with time picker │ └── LiveMapScreen.jsx # Leaflet map (lazy-loaded): route, GPS ├── components/ │ ├── Icons.jsx # 12 SVG icon components │ ├── components.jsx # DrumColumn, ElevationProfile, CountdownCard, etc. │ ├── LegDetailModal.jsx # Leg detail bottom sheet │ ├── ReassignModal.jsx # Runner reassignment modal │ ├── AdjustPaceModal.jsx # Manual pace override drum picker │ └── ObservedPaceAdjuster.jsx # GPS observation adjustment (±30s/mi) ├── hooks/ │ └── useRaceState.js # All state, effects, derived values, handlers ├── utils/ │ ├── raceUtils.js # APP_VERSION, RACE_REGISTRY, storage, CSV, utilities │ ├── forecastEngine.js # Pure math: forecasting, fatigue, summaries (81 tests) │ └── gpxUtils.js # GPS: haversine, parseGpx, snapToRoute, calcPace ├── data/ │ ├── legElevationProfiles.js # Pre-computed elevation profiles per leg (~64 KB) │ └── races/ │ ├── htc_2026.json # HTC race metadata (36 legs, 12 runners) │ └── rto_2026.json # RTO race metadata (36 legs, 12 runners) ├── scripts/ # Python/JS data-prep scripts (not used at runtime) ├── test/ # GpxTrackerTestHarness (T1–T10) └── docs/ # BACKLOG, HANDOFF, APP_DOCUMENTATION, code reviews

Vite Project Root (run commands here)

C:\Users\garre\OneDrive\HTC_App\relay-timer\

relay-timer/ ├── index.html # HTML shell with PWA meta tags, font loading ├── package.json # Dependencies: react, react-dom, leaflet (only 3 runtime deps) ├── vite.config.js # Vite config with React plugin ├── eslint.config.js # ESLint config ├── public/ │ ├── sw.js # Service worker: caching strategies, font precache, tile cache │ ├── manifest.json # PWA manifest: icons, display mode, theme │ ├── favicon.svg │ ├── fonts/ # 8 self-hosted WOFF2 font files + fonts.css │ ├── gpx/ # 36 GPX route files (leg_01.gpx → leg_36.gpx) │ └── icons/ # PWA icons: 180×180, 192×192, 512×512 └── src/ # Copies of source files (synced from RelayTimer_v1.5/)

Workflow

✏️ Edit in RelayTimer_v1.5/
📋 Copy to relay-timer/src/
🔨 npm run build
🚀 /deployHTC → Cloudflare
4

Screens & 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).

Pre-race

🏁 Start Screen

Race banner with stats, time-of-day drum picker to set race start, and "Start Race" button. Shown when raceStartSec === null.

tab: home

🏠 Home Screen

Race progress bars, 4-card status grid (pace/finish/elapsed/legs), and "On Course" + "Up Next" runner cards with elevation and countdown.

tab: exchange

🔄 Exchange Screen

Primary race-day screen. Runner In button, countdown card, elevation profile, Up Next queue, race position strip, and van navigation row.

tab: timeline

📋 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.

tab: runners

👟 Runners Screen

Card per runner showing miles, base pace, leg chips (tappable), and cumulative stats for completed legs.

tab: gps

📍 GPS Screen

Real-time pace observation via device GPS + GPX route snapping. Two-stage commit, observed pace adjuster (±30s/mi), inline Leaflet map.

tab: settings

⚙️ 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:

🔵 Runner In Button

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.

📊 Countdown Card

Large timer with progress bar, estimated finish, miles done/remaining, current time. Shows "+M:SS" when overdue.

🗺️ Van Navigation Row

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)

PositionContent
LeftRace short name + legs completed/total (e.g. "HTC · 5/36"), current runner + leg number
CenterRemaining countdown (M:SS) for active leg
RightStatus 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
5

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

DialogMessage / 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 CheckpointDiff 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."
6

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

buildForecast()
forecast[36]
UI renders ETAs + deltas
Runner In / GPS obs
Re-forecast

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

OBS
GPS Observation
Priority 1
ADJ
GPS + Adjustment
Priority 1 (adj ≠ 0)
MAN
Manual Override
Priority 2
EST
Model Estimate
Priority 3 (default)
Pace floor: All observed and manual paces are clamped to 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))
ParameterDefaultMeaning
k0.1818% max pace degradation at asymptote
L4563% 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

KeyPurpose
relayTimer_${raceId}_v1Full state — saved on every state change
relayTimer_cp_0/1/2Rolling checkpoint slots — written on every Runner In
relayTimer_cp_indexRound-robin index for checkpoint slots
relay_color_modeColor mode preference (auto/light/dark)
relay_selected_raceCurrently selected race ID
relayTimer_htc2026_v4Legacy 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.

7

Service Worker

Cache version: v2. Four caches:

CacheStrategyContents
app-shell-v2Stale-while-revalidate/, /index.html, all same-origin JS/CSS
fonts-v2Cache-first (precached on install)8 WOFF2 files + fonts.css
gpx-routes-v2Cache-firstGPX files (cached on first access)
carto-tiles-v2Cache-firstCartoDB 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."

8

GPS System

Route Snapping (gpxUtils.js)

FunctionPurpose
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

1. Tap "Lock Runner Position" → getCurrentPosition (high accuracy, 10s timeout)
2. Position snapped to GPX route → distance + pace calculated
3. Result staged in pendingObs — not yet committed to forecast
4. User reviews results (distance, pace, GPS accuracy, snap error)
5. Unrealistic pace (<4:00/mi): warning shown, button changes to "Apply Anyway"
6. "Apply to Forecast" → handleObservationCapture clamps to 4:00/mi floor, writes to observedPaces + legPaceOverrides
7. ObservedPaceAdjuster shown for ±30s/mi fine-tuning (10s increments)
8. "Clear Observation" removes from observedPaces and legPaceOverrides, resets GPS lock state
9

Race 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]
    }
  ]
}
10

CSS & Theming

Design System

RoleFont
Display / HeadersBebas Neue
Body textNunito
Condensed labelsBarlow Condensed
Monospace / DataDM 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

ClassPurpose
.runner-in-btn.pulseGentle pulsing animation (2.5s period, 8px box-shadow, 0.2 opacity)
.runner-in-btn.overdueRed urgent state
.elev-info-rowTransparent row above elevation SVG on Exchange screen
.elev-info-noteRight-aligned note text within elevation info row
.home-runner-detailSingle-line truncation with nowrap + ellipsis
.pace-source-badgeColor-coded source indicator (EST/MAN/OBS/ADJ)
.exchange-overdue / .exchange-red / etc.Exchange tab dynamic coloring
11

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).

12

Export Formats

CSV Export (27 columns)

Filename: relay_timer_{raceId}_{YYYYMMDD_HHMM}.csv

Column GroupFields
Leg infoLeg, Runner, Distance, Elev Gain/Loss
ModelElev Adj Factor, Fatigue Multiplier, Combined Adj Factor
EstimatesBase Pace, Est Pace, Pace Source, Est Duration, Est Start, Est Finish
ActualsActual Pace, Actual Duration, Actual Start, Actual Finish
DeltasDelta (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).

13

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 GroupCoverage
FormattingformatDuration, formatPace, parsePace, formatTimeOfDay, parseTimeOfDay
Elevation adjustmentflat, climb, loss, net, zero-distance
Fatigue modelfresh, progressive, asymptote, estimateLegDuration
Pace priority chainEST, MAN, OBS, ADJ
Pace floor clampMIN_PACE_SEC_PER_MILE = 240
Forecast3-leg race, cascading, badges, overrides, GPS obs, midnight rollover, zero-distance
Actuals managementlogRunnerIn, logRunnerOut, editActual, undoRunnerIn
Team statuscompleted count, delta, overdue, summary
Runner summarieslegs, miles, pace, delta

test/GpxTrackerTestHarness.jsx has T1–T10 covering parse, haversine, snap, and pace calculations.

14

Simulation Mode

Developer-only feature under Settings → Developer Tools.

⚡ Speed Options

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.

🔄 Behavior

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.

15

Handler Reference

All handlers defined in useRaceState.js with useCallback:

HandlerPurpose
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
16

File-by-File Reference

FileSizeDescription
RelayTimer.jsx~115 linesApp shell: TABS array, pre-race/race render, nav bar, race-switch dialog
hooks/useRaceState.js~253 linesAll useState, buildRunners(), clock effects, persistence, derived state, all handlers
utils/forecastEngine.js~568 linesPure math: constants, formatting, pace chain, buildForecast, getTeamStatus, getRunnerSummaries, actuals, 81 tests
utils/raceUtils.js~183 linesAPP_VERSION ("1.8.1"), RACE_REGISTRY, RACE_LIST, TOD_ICONS, SIM_SPEEDS, URL builders, storage, CSV export
utils/gpxUtils.js~111 lineshaversine, parseGpx, projectPointOnSegment, snapToRoute, getTileUrlsForBounds, calcPace
screens/HomeScreen.jsx~86 linesProgress bars, status grid, runner cards
screens/ExchangeScreen.jsx~100 linesRunner In button, pace button, van nav row, undo bar, position strip, countdown, elevation, Up Next queue, race complete
screens/TimelineScreen.jsx~43 linesVertical timeline, auto-scroll, spine dots, delta chips, reassign buttons
screens/RunnersScreen.jsx~26 linesRunner cards, leg chips, cumulative stats
screens/GpxTrackerScreen.jsx~243 linesGPX loading, GPS flow, results card, quality indicators, two-stage commit, ObservedPaceAdjuster, inline map
screens/SettingsScreen.jsx~195 linesVersion badge, dev tools, offline maps, save/export, checkpoints, appearance, race selector, reset
screens/LiveMapScreen.jsx~473 linesLeaflet init, GPX route rendering, expected position interpolation, GPS watch, tile download
components/components.jsx~217 linesDrumColumn, TimeOfDayDrum, ElevationProfile, LegMiniMap, NavStatusPill, DiffBadge, DeltaChip, RacePositionStrip, CountdownCard
components/LegDetailModal.jsx~86 linesFull leg detail sheet with map, stats, time editors
components/ReassignModal.jsx~39 linesRunner list, add-new option
components/AdjustPaceModal.jsx~31 linesPace 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 lines12 SVG icon components
data/legElevationProfiles.js~64 KBPre-computed [distance, elevation] arrays per leg number
data/races/htc_2026.jsonHTC race metadata, 36 legs, exchange coords, van directions
data/races/rto_2026.jsonRTO race metadata, 36 legs
17

Version History

1.8.1
2026-06-25
BL-42: Flicker resolution (verified React 19 auto-batching, no code change needed). BL-51: UI polish — Runner In pulse toned down, Van Directions + Leg Map PDF combined into flex row, elevation profile height increased (72→96), leg notes relocated to elevation info row, Home screen elevation wrapping fixed with condensed format + ellipsis.
1.8.0
2026-06-24
BL-50: Monolith split — 1623-line RelayTimer.jsx split into 16 files (7 screens, 4 modals/components, Icons, components, raceUtils, useRaceState hook, slim App shell). React.memo on all screens, useCallback on all handlers.
1.7.8
BL-52: Fix GPS garbage pace bypassing forecast clamp — clamp at source in handlers.
1.7.7
BL-49: SW + PWA hardening (stale-while-revalidate, font precache, update toast, manifest).
1.7.6
BL-48: CSS extraction, PDF URL guard, duplicate SW removal.
1.7.5
BL-47: GPX/tile code consolidation into gpxUtils.js.
1.7.4
BL-45/46: Bug fixes (CSV, formatPace, race-switch buttons) + orphan file deletion.
1.7.3
BL-44: Elevation gain/loss badge on Exchange screen.
1.7.2
BL-43: File organization + GPS harness removal. BL-39: PWA icons.
1.7.1
BL-38: GPS pace guardrails (4:00/mi floor + UI warning).
1.7.0
BL-36/37: forecastEngine consolidation + code review fixes.
End of documentation. This file, combined with the source files in RelayTimer_v1.5/, provides everything needed to rebuild RelayTimer v1.8.1 from scratch.