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 — on iPhone (primary, in-van/on-foot use) and iPad (co-captain / basecamp dashboard, sidebar layout with more screen real estate).
~$20/team SaaS with official race organizer partnerships.
Technology Stack
| Layer | Technology | Version |
|---|---|---|
| Framework | React | 19.2.6 |
| Build tool | Vite | 8.0.16 |
| Maps | Leaflet | 1.9.4 |
| Styling | CSS custom properties + hand-written CSS (separate phone/iPad stylesheets) | — |
| PWA | Custom service worker (sw.js), shared by both builds | — |
| Hosting | Cloudflare Pages (via GitHub push) — two projects, one repo | — |
| Fonts | Self-hosted WOFF2 (Bebas Neue, Barlow Condensed, Nunito, DM Mono) | — |
Project Structure
RelayTimer is a single-repo, two-build-target monorepo. One Vite project root (relay-timer/) with two separate Vite configs producing two separate static sites from the same shared logic layer.
Build Targets & Deploy Destinations
| Command | Output | Cloudflare Pages Project |
|---|---|---|
| npm run build | dist/phone/ | htc-app (phone) |
| npm run build:ipad | dist/ipad/ | htc-app-ipad (iPad) |
| npm run build:all | both | both (used by /deployHTC) |
| npm run dev | phone dev server | http://localhost:5173 |
| npm run dev:ipad | iPad dev server | http://localhost:5174 |
Import Aliases
Both Vite configs define: @utils/ → src/utils/, @hooks/ → src/hooks/, @components/ → src/components/, @data/ → src/data/.
The iPad config additionally defines @phone/ → src/phone/, which lets the iPad build reuse HomeScreen.jsx, LiveMapScreen.jsx, and relayTimer.css without duplicating them.
Cross-Platform Discipline
useRaceState.js or utils/ — never forked per platform. If logic is duplicated into each screen copy, bugs will exist on both platforms identically (see v1.10.1 StartScreen clock bug).Screens & UI
The app has 6 tabs plus a Start Screen (before race) and a Race Complete screen (all 36 legs done). Navigation chrome differs by platform — bottom tab bar on phone, left sidebar on iPad — but screen behavior (state, handlers, forecast data) is identical; only layout/composition differs.
🏁 Start Screen
Race banner with stats, time-of-day drum picker, "Start Race" button. iPad: centered 560px column, larger typography.
🏠 Home Screen
Race progress bars, 4-card status grid, "On Course" + "Up Next" runner cards. No iPad override — imported directly from @phone.
🔄 Exchange Screen
Primary race-day screen. Runner In button, countdown card, elevation profile, van navigation row, Up Next queue. iPad: 3-panel grid (300px / 1fr / 300px).
📋 Timeline Screen
36-leg vertical timeline, spine dots, delta chips. Phone: modal overlay on tap. iPad: persistent detail panel via LegDetailModal inline prop.
👟 Runners Screen
Card per runner with leg chips, cumulative stats. iPad: 3-column CSS grid showing all 9 runners, no scroll.
📍 GPS Screen
GPS lock, route snap, two-stage pace commit, ±30s adjuster. iPad: map always visible in right panel (inlineHeight="100%").
⚙️ Settings Screen
Dev tools, appearance, race selector, export, checkpoints. iPad: category/detail split layout replacing phone's single scrolling column.
Navigation Chrome
Bottom tab bar (6 SVG-icon buttons). Nav bar above: race info + progress (left), remaining countdown M:SS (center), status pill + projected finish (right). Sim banner and SW-update toast render above the nav bar when active.
Left .sidebar (72px wide, icon-only tabs, exchange-tab color-coded) + .top-bar (56px, same left/center/right info as phone's nav bar). .app-shell is position:fixed; inset:0 — fills the viewport with no page scroll; only individual panels scroll internally.
Modals & Overlays
LegDetailModal
Triggered from Timeline, Runners (tap leg chip), or Exchange (tap Up Next). Contains: handle bar, header, LegMiniMap (read-only route map), PDF link, stats rows, Elevation Profile, pace source/fatigue, observed distance, delta vs forecast, notes, Google Maps + Offline Directions + PDF buttons, and Start/Finish time editors.
inline prop (default false, added v1.9.0): when true, renders without modal backdrop/close button, for use as iPad Timeline's persistent detail panel. Phone overlay behavior is unaffected.AdjustPaceModal
Drum picker for pace override in range 4:00–20:59/mi. Shows runner info, base pace, fatigue %, and projected finish preview. Apply / Clear / Cancel.
ReassignModal
Runner list with miles/pace, "+ Add new runner" input (9:00/mi default), current-assignment label, Confirm Reassignment. Clears pace override and GPS observation for the reassigned leg.
Confirmation Dialogs
| Dialog | 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 vs. current, actuals that would be lost. Pre-revert checkpoint auto-saved. |
| Switch Race | "Switching to [race name] will clear all current race data." |
Data Flow & State Management
Architecture
All state lives in useRaceState.js (src/hooks/) using useState/useMemo/useCallback — no Redux, no external stores, and never forked per platform. Both src/phone/RelayTimer.jsx and src/ipad/RelayTimer.jsx call this hook and pass results down as props. Screen components use React.memo; handlers use useCallback.
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, tab, colorMode, selectedRaceId, lastLoggedLeg, undoVisible,
swUpdated, confirmSwitchRaceId, runnerLegModal
// Simulation state
simEnabled, simSpeed, simNowSec, simPaused
effectiveNow // derived: simEnabled ? (simNowSec ?? raceStartSec ?? nowSec) : nowSec
// Derived state (via useMemo)
runners, forecast, teamStatus
Forecast Pipeline
Completed legs lock and cascade-anchor the next leg's start. Active leg with GPS observation uses split-time projection: startSec + elapsedAtObservation + (remainingMiles × effectivePace).
Pace Priority Chain
MIN_PACE_SEC_PER_MILE (240 = 4:00/mi), applied at the source in handlers and again in getEffectivePace.Fatigue Model
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 cumulative miles |
First leg for any runner 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 (round-robin, 3 slots) — written on every Runner In |
relayTimer_cp_index | Round-robin pointer |
relay_color_mode | Color mode preference (auto / light / dark) |
relay_selected_race | Currently selected race ID |
localStorage namespaces are automatically isolated — no key prefixing needed.iPad Platform Specifics
PWA Manifest Differences
| Field | Phone (manifest.json) | iPad (manifest.ipad.json) |
|---|---|---|
| display | standalone | fullscreen (hides Safari chrome) |
| orientation | portrait | landscape |
| background_color / theme_color | #131A24 | #131A24 (same) |
| icons | same icon set | same icon set |
Orientation Lock
Manifest orientation: landscape isn't always honored by Safari, so src/ipad/main.jsx supplements with the Screen Orientation API:
if (screen.orientation && screen.orientation.lock) {
screen.orientation.lock('landscape').catch(() => {});
}
A CSS fallback in relayTimer.ipad.css shows a "rotate to landscape" overlay and hides .content-area when @media (orientation: portrait) matches. Real-hardware verification remains outstanding (Phase 4).
Safe Area Insets
relayTimer.ipad.css applies env(safe-area-inset-*) to .sidebar (top/bottom/left) and .top-bar (left/right) for iPad Pro models with Face ID camera housings.
Splash Screens (added v1.10.2)
| File | Target Device |
|---|---|
public/icons/splash-2048x2732.png | 12.9" iPad Pro |
public/icons/splash-1668x2388.png | 11" iPad Pro / iPad Air |
public/icons/splash-1536x2048.png | iPad 10.2" / iPad mini |
Each is the app logo (favicon.svg, rasterized) centered on #131A24 background. Generated with a one-off sharp script (not a project dependency — installed temporarily, images generated, removed). No SW changes needed: the existing stale-while-revalidate handler covers any same-origin asset path.
Remaining Phase 4 Work
Service Worker
Shared by both builds — unchanged since v1.8.1. 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 |
GPS System
Unchanged since v1.8.1 — see src/utils/gpxUtils.js and src/phone/screens/LiveMapScreen.jsx (shared by both platforms via @phone alias).
| Function | Purpose |
|---|---|
parseGpx(gpxString) | Parses GPX XML into [{lat, lon, distFromStart}] with cumulative haversine distances (miles) |
snapToRoute(lat, lon, path) | Perpendicular projection onto nearest segment. Returns {distanceMiles, snapErrorFeet} |
calcPace(elapsed, dist) | Returns {paceStr, mph, secPerMile} |
haversine(a, b) | Great-circle distance in miles |
haversineMeters(a, b) | Great-circle distance in meters (for LiveMapScreen) |
getTileUrlsForBounds(bounds, minZ, maxZ) | Generates CartoDB tile URLs for offline caching |
Exchange-proximity color regime: green (>1 mi) → yellow (<1 mi) → red (<0.3 mi). GPX loading state is derived (v1.10.1 refactor for lint compliance) rather than reset via synchronous setState in an effect — this also closed a latent race condition where a slow stale GPX fetch could overwrite a newer one.
npm run dev:ipad throws a React "Invalid hook call" error on the GPS tab. Does not reproduce in the production build. Low urgency; not investigated as of this snapshot.Race Data Format
Unchanged since v1.8.1. Schema v1.1.0 JSON per race (htc_2026.json, rto_2026.json) — race metadata, pace model defaults, difficulty reference, 36 legs (exchange coordinates, van directions, elevation, PDF links), runner roster. See src/data/races/ — do not modify structure.
{
"_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,
"accent_color": "#D64F1E",
"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 },
"legs": [{ "leg_number": 1, "distance_miles": 6.26, "difficulty_label": "MD",
"elevation_gain_ft": 0, "elevation_loss_ft": 2036,
"exchange_lat": 45.304771, "exchange_lng": -121.759188, ... }],
"runners": [{ "id": "htc_v1_1", "relay_pace_sec_per_mile": 540,
"legs_assigned": [1, 7, 13, 19, 25, 31] }]
}
CSS & Theming
Two separate stylesheets: src/phone/relayTimer.css (design tokens + phone layout, also imported by iPad for shared components/colors) and src/ipad/relayTimer.ipad.css (iPad-only: sidebar, top bar, panel grids, orientation/safe-area). src/tokens.css independently duplicates some of the same custom properties — known duplication since v1.8.3, not yet consolidated (see BACKLOG).
Design system (fonts, color palette, border radius, shadows, color-mode switching via data-color-mode) is unchanged since v1.8.1.
| Font Role | Family |
|---|---|
| Display / Headers | Bebas Neue |
| Body text | Nunito |
| Condensed labels | Barlow Condensed |
| Monospace / Data | DM Mono |
Color palette: Mt. Hood / Pacific Northwest inspired. Light: forest greens (#2D4A35, #1A6B3C), earth accents (#D64F1E). Dark: deep navy (#0D1117, #131A24), bright greens (#27C97A), electric accents (#FF5C00). Three color modes via data-color-mode attribute: auto (follows system), light, dark. Stored in localStorage as relay_color_mode.
Key Components
Unchanged since v1.8.1 (src/components/) except:
inline propAdded v1.9.0. When inline={true}, renders without modal backdrop/close button for use as iPad Timeline's persistent detail panel. Phone overlay behavior unaffected.
Carries a deliberate, unfixed react-hooks/exhaustive-deps warning on its onChange dependency. Fixing it risks infinite-render regressions for callers passing non-memoized onChange. Out of scope pending a dedicated audit of every caller on both platforms.
| Component | Description |
|---|---|
| DrumColumn / TimeOfDayDrum | iOS-style scroll picker. ITEM_H=44px, debounced 80ms scroll handler, 12-hour AM/PM picker. |
| ElevationProfile | SVG chart, viewBox 400×96. Animated runner dot (opacity pulse 1→0.15→1 at 1.2s). Two modes: default badge overlay, hideBadge info row (Exchange screen). |
| CountdownCard | Large countdown timer with progress bar, turns red "+M:SS" when overdue. 2×2 grid: finish time, miles done, current time, remaining miles. |
| RacePositionStrip | 36 tiny blocks: complete=green, active=pulsing accent, overdue=red pulse, upcoming=gray. |
| LegMiniMap | Read-only Leaflet (all interaction disabled). Green start dot, red end dot, green polyline. 180px height, auto-fit bounds. |
| NavStatusPill | Colored badge: neutral / overdue / ahead-strong / ahead / on-pace / behind. |
| DiffBadge / DeltaChip | Difficulty label (E/M/MC/MD) and ▲/▼ time delta with color classes. |
Export Formats
Unchanged since v1.8.1.
Filename: relay_timer_{raceId}_{YYYYMMDD_HHMM}.csv. Columns: Leg, Runner, Base Pace, Distance, Elev Gain/Loss, Elev Adj Factor, Fatigue Multiplier, Combined Adj Factor, Est Pace, Pace Source, Est Duration, Est Start, Est Finish, Actual Pace, Actual Duration, Actual Start, Actual Finish, Delta, Cumulative Delta.
Filename: relay_timer_{raceId}_{YYYYMMDD_HHMM}.json. Includes raceStartSec, actuals, legPaceOverrides, observedPaces, runnerOverrides, paceModel. Re-importable via "Load State from File" (auto-checkpoints before loading).
Build & Deploy
Development
cd C:\Users\garre\OneDrive\HTC_App\relay-timer
npm run dev # Phone dev server — http://localhost:5173
npm run dev:ipad # iPad dev server — http://localhost:5174
Production Build
npm run build # → dist/phone/
npm run build:ipad # → dist/ipad/
npm run build:all # both — used by /deployHTC before every deploy
Deploy
/deployHTC skill: (1) verifies version consistency across src/VERSION.js and CLAUDE.md, (2) commits all changes, (3) pushes to GitHub, (4) Cloudflare Pages auto-deploys both htc-app (phone) and htc-app-ipad (iPad) from the same push.
Tests
node src/utils/forecastEngine.js # 81 assertions
| 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 per runner |
test/GpxTrackerTestHarness.jsx has T1–T10 covering parse, haversine, snap, pace (shared logic, platform-agnostic).
Simulation Mode
Unchanged since v1.8.1 — Settings → Developer Tools, both platforms.
1×, 10×, 60×, 120×, 240× — increments simNowSec by speed value every real second. Pause/Resume. Purple status banner at top shows speed 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. Available on both phone and iPad; developer-only.
Handler Reference
All defined in src/hooks/useRaceState.js with useCallback:
| Handler | Purpose |
|---|---|
| handleSetStart(sec) | Set race start time, start leg 1, switch to home tab |
| handleRunnerIn(legNumber) | Log finish, auto-start next, clear overrides, write checkpoint, show undo bar |
| handleUndo() | Remove last Runner In finish + associated next-leg start |
| handleReassign(n, runnerId, runnerName) | Reassign leg to different/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, checkpoints, and save data |
| handleExportCSV() | Generate and download CSV |
| handleSaveJSON() | Generate and download JSON state snapshot |
| handleLoadJSON(file) | Load state from file (checkpoints current state first) |
| handleRevertToCheckpoint(cp) | Restore state from checkpoint |
| handleRaceSwitch(newId) | Switch race (with confirmation if in progress) |
| handleSetColorMode(mode) | Set color mode and persist to localStorage |
| handleToggleSim() | Toggle simulation mode |
| handleToggleSimPause() | Toggle simulation pause/resume |
File-by-File Reference
Shared (src/ — never platform-specific)
| File | ~Lines | Purpose |
|---|---|---|
hooks/useRaceState.js | 252 | All state, effects, derived values, handlers |
utils/forecastEngine.js | 568 | Pure math: forecasting, fatigue, summaries, 81-assertion test harness |
utils/raceUtils.js | 183 | APP_VERSION, RACE_REGISTRY, storage, CSV, formatting/URL helpers |
utils/gpxUtils.js | 110 | haversine, parseGpx, snapToRoute, calcPace, tile URL builder |
components/components.jsx | 216 | DrumColumn, TimeOfDayDrum, ElevationProfile, LegMiniMap, NavStatusPill, DiffBadge, DeltaChip, RacePositionStrip, CountdownCard |
components/LegDetailModal.jsx | 103 | Full leg detail sheet/panel (supports inline) |
components/ReassignModal.jsx | 39 | Runner reassignment |
components/AdjustPaceModal.jsx | 31 | Manual pace override drum |
components/ObservedPaceAdjuster.jsx | 69 | GPS observation ±30s/mi adjustment |
components/Icons.jsx | 11 | 12 SVG icon components |
data/legElevationProfiles.js | ~64 KB | Pre-computed elevation arrays per leg |
data/races/htc_2026.json, rto_2026.json | — | Race metadata — do not modify structure |
Phone (src/phone/)
| File | ~Lines | Purpose |
|---|---|---|
RelayTimer.jsx | 115 | App shell: tab bar, nav bar, race-switch dialog |
screens/HomeScreen.jsx | 86 | Home tab (also used directly by iPad via @phone alias) |
screens/ExchangeScreen.jsx | 100 | Exchange tab |
screens/TimelineScreen.jsx | 43 | Timeline (modal overlay for leg detail) |
screens/RunnersScreen.jsx | 26 | Runners tab |
screens/GpxTrackerScreen.jsx | 242 | GPS tab |
screens/SettingsScreen.jsx | 195 | Settings tab |
screens/StartScreen.jsx | 29 | Pre-race screen |
screens/LiveMapScreen.jsx | 472 | Leaflet map (also used directly by iPad via @phone alias) |
iPad (src/ipad/)
| File | ~Lines | Purpose |
|---|---|---|
RelayTimer.jsx | 130 | App shell: sidebar, top bar, screen switcher |
screens/ExchangeScreen.jsx | 96 | 3-panel layout |
screens/TimelineScreen.jsx | 57 | List + persistent detail panel |
screens/RunnersScreen.jsx | 26 | 3-column grid |
screens/GpxTrackerScreen.jsx | 246 | Side-by-side, map always visible |
screens/SettingsScreen.jsx | 219 | Category/detail split |
screens/StartScreen.jsx | 34 | Centered column, larger type |
iPad has no HomeScreen.jsx or LiveMapScreen.jsx — both imported directly from @phone.
Version History
index.html; package-lock.json re-synced (dropped dead vite-plugin-static-copy subtree from v1.8.3 migration); rewrote this documentation snapshot; archived stale v1.8.1 snapshot.StartScreen.jsx, both platforms); cleared full lint backlog (21 errors → 0) including all 6 react-hooks/set-state-in-effect violations via derived-state refactors.GpxTrackerScreen, SettingsScreen, StartScreen real iPad layouts — last 3 @phone placeholders replaced.ExchangeScreen, TimelineScreen, RunnersScreen real iPad layouts; LegDetailModal gained inline prop.src/phone/ + src/ipad/ + shared layers; added vite.config.ipad.js, iPad manifest/HTML/CSS scaffold with @phone placeholder screens.RelayTimer.jsx split into 16 files.docs/Archive/APP_DOCUMENTATION_v1.8.1.md and related archived files.relay-timer/src/, provides everything needed to rebuild RelayTimer v1.10.2 from scratch on both platforms.