Complete Application Documentation

RelayTimer v1.10.2

A React PWA for van captains managing Hood to Coast, RTO, and 36-leg relay races — fully offline after first load. Now with a dual-target monorepo: iPhone + iPad.

React 19.2.6 + Vite 8
Offline-first PWA
Monorepo · Phone + iPad
81 forecast engine tests
Cloudflare Pages (2 projects)
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 — on iPhone (primary, in-van/on-foot use) and iPad (co-captain / basecamp dashboard, sidebar layout with more screen real estate).

💼 Business Model

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

2

Technology Stack

LayerTechnologyVersion
FrameworkReact19.2.6
Build toolVite8.0.16
MapsLeaflet1.9.4
StylingCSS custom properties + hand-written CSS (separate phone/iPad stylesheets)
PWACustom service worker (sw.js), shared by both builds
HostingCloudflare Pages (via GitHub push) — two projects, one repo
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

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.

relay-timer/ ← Vite project root ├── package.json # Scripts: dev, dev:ipad, build, build:ipad, build:all ├── vite.config.js # Phone build (root: src/phone, out: dist/phone) ├── vite.config.ipad.js # iPad build (root: src/ipad, out: dist/ipad, +@phone alias) ├── public/ # Shared static assets for BOTH builds │ ├── sw.js # Service worker (shared, cache-version gated) │ ├── manifest.json # Phone PWA manifest (standalone, portrait) │ ├── manifest.ipad.json # iPad PWA manifest (fullscreen, landscape) │ ├── fonts/ # 8 self-hosted WOFF2 files + fonts.css │ ├── gpx/ # 36 GPX route files (leg_01.gpx – leg_36.gpx) │ └── icons/ # PWA icons + iPad splash PNGs (added v1.10.2) ├── src/ │ ├── VERSION.js # Single version source of truth — bump here │ ├── phone/ # PHONE-SPECIFIC (bottom tab nav) │ │ ├── RelayTimer.jsx # Phone app shell │ │ ├── relayTimer.css # Phone stylesheet (also imported by iPad via @phone alias) │ │ ├── main.jsx │ │ ├── index.html │ │ └── screens/ # HomeScreen, ExchangeScreen, TimelineScreen, │ │ # RunnersScreen, GpxTrackerScreen, SettingsScreen, │ │ # StartScreen, LiveMapScreen │ ├── ipad/ # IPAD-SPECIFIC (sidebar nav, multi-panel layouts) │ │ ├── RelayTimer.jsx # iPad app shell: sidebar, top bar, screen switcher │ │ ├── relayTimer.ipad.css # iPad layout CSS (grids, sidebar, safe-area) │ │ ├── main.jsx # + orientation lock │ │ ├── index.html # + apple-touch-startup-image links (v1.10.2) │ │ └── screens/ # ExchangeScreen, TimelineScreen, RunnersScreen, │ │ # GpxTrackerScreen, SettingsScreen, StartScreen │ │ # (HomeScreen + LiveMapScreen: no iPad override — imported from @phone) │ ├── components/ # SHARED display primitives (both platforms) │ │ ├── Icons.jsx │ │ ├── components.jsx # DrumColumn, ElevationProfile, CountdownCard, etc. │ │ ├── LegDetailModal.jsx # supports `inline` prop for iPad panel mode │ │ ├── ReassignModal.jsx │ │ ├── AdjustPaceModal.jsx │ │ └── ObservedPaceAdjuster.jsx │ ├── hooks/ │ │ └── useRaceState.js # All state, effects, derived values, handlers (NEVER forked) │ ├── utils/ │ │ ├── raceUtils.js │ │ ├── forecastEngine.js # Pure math, 81 assertions │ │ └── gpxUtils.js │ └── data/ │ ├── legElevationProfiles.js # ~64 KB pre-computed elevation arrays │ └── races/ │ ├── htc_2026.json │ └── rto_2026.json ├── scripts/ # Python/JS data-prep (not used at runtime) ├── test/ # GpxTrackerTestHarness (T1–T10) └── docs/ # BACKLOG, HANDOFF, APP_DOCUMENTATION, Archive/

Build Targets & Deploy Destinations

CommandOutputCloudflare Pages Project
npm run builddist/phone/htc-app (phone)
npm run build:ipaddist/ipad/htc-app-ipad (iPad)
npm run build:allbothboth (used by /deployHTC)
npm run devphone dev serverhttp://localhost:5173
npm run dev:ipadiPad dev serverhttp://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

Rule: Phone and iPad screens are presentation-only. Anything stateful or logic-bearing belongs in 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).
4

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.

Pre-race

🏁 Start Screen

Race banner with stats, time-of-day drum picker, "Start Race" button. iPad: centered 560px column, larger typography.

home

🏠 Home Screen

Race progress bars, 4-card status grid, "On Course" + "Up Next" runner cards. No iPad override — imported directly from @phone.

exchange

🔄 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

📋 Timeline Screen

36-leg vertical timeline, spine dots, delta chips. Phone: modal overlay on tap. iPad: persistent detail panel via LegDetailModal inline prop.

runners

👟 Runners Screen

Card per runner with leg chips, cumulative stats. iPad: 3-column CSS grid showing all 9 runners, no scroll.

gps

📍 GPS Screen

GPS lock, route snap, two-stage pace commit, ±30s adjuster. iPad: map always visible in right panel (inlineHeight="100%").

settings

⚙️ Settings Screen

Dev tools, appearance, race selector, export, checkpoints. iPad: category/detail split layout replacing phone's single scrolling column.

Navigation Chrome

📱 Phone

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.

🖥️ iPad

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.

5

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

DialogContent
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 vs. current, actuals that would be lost. Pre-revert checkpoint auto-saved.
Switch Race"Switching to [race name] will clear all current race data."
6

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

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

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

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

multiplier = 1 + k × (1 − e^(−cumulativeMiles / L))
ParameterDefaultMeaning
k0.1818% max pace degradation at asymptote
L4563% 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

KeyPurpose
relayTimer_${raceId}_v1Full state — saved on every state change
relayTimer_cp_0/1/2Rolling checkpoint slots (round-robin, 3 slots) — written on every Runner In
relayTimer_cp_indexRound-robin pointer
relay_color_modeColor mode preference (auto / light / dark)
relay_selected_raceCurrently selected race ID
Phone and iPad run on different Cloudflare origins, so their localStorage namespaces are automatically isolated — no key prefixing needed.
7

iPad Platform Specifics

PWA Manifest Differences

FieldPhone (manifest.json)iPad (manifest.ipad.json)
displaystandalonefullscreen (hides Safari chrome)
orientationportraitlandscape
background_color / theme_color#131A24#131A24 (same)
iconssame icon setsame 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)

FileTarget Device
public/icons/splash-2048x2732.png12.9" iPad Pro
public/icons/splash-1668x2388.png11" iPad Pro / iPad Air
public/icons/splash-1536x2048.pngiPad 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

Requires physical iPad hardware — not done as of this snapshot: (1) test PWA install on actual hardware, (2) verify offline behavior (SW, GPX tiles, fonts) on-device, (3) confirm orientation lock behavior on real Safari, (4) full race simulation on iPad using the sim clock.
8

Service Worker

Shared by both builds — unchanged since v1.8.1. 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
If a user installs both phone and iPad PWAs on the same device, each Cloudflare origin gets its own isolated SW cache — no collision, correct behavior by construction.
9

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

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

Known dev-server-only issue: 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.
10

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] }]
}
11

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

12

Key Components

Unchanged since v1.8.1 (src/components/) except:

LegDetailModal — inline prop

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

TimeOfDayDrum — known lint warning

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.

ComponentDescription
DrumColumn / TimeOfDayDrumiOS-style scroll picker. ITEM_H=44px, debounced 80ms scroll handler, 12-hour AM/PM picker.
ElevationProfileSVG 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).
CountdownCardLarge countdown timer with progress bar, turns red "+M:SS" when overdue. 2×2 grid: finish time, miles done, current time, remaining miles.
RacePositionStrip36 tiny blocks: complete=green, active=pulsing accent, overdue=red pulse, upcoming=gray.
LegMiniMapRead-only Leaflet (all interaction disabled). Green start dot, red end dot, green polyline. 180px height, auto-fit bounds.
NavStatusPillColored badge: neutral / overdue / ahead-strong / ahead / on-pace / behind.
DiffBadge / DeltaChipDifficulty label (E/M/MC/MD) and ▲/▼ time delta with color classes.
13

Export Formats

Unchanged since v1.8.1.

📊 CSV Export — 27 columns

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.

📁 JSON Export — full state snapshot

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

14

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 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 per runner

test/GpxTrackerTestHarness.jsx has T1–T10 covering parse, haversine, snap, pace (shared logic, platform-agnostic).

15

Simulation Mode

Unchanged since v1.8.1 — Settings → Developer Tools, both platforms.

⚡ Speeds

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.

🔄 Behavior

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.

16

Handler Reference

All defined in src/hooks/useRaceState.js with useCallback:

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

File-by-File Reference

Shared (src/ — never platform-specific)

File~LinesPurpose
hooks/useRaceState.js252All state, effects, derived values, handlers
utils/forecastEngine.js568Pure math: forecasting, fatigue, summaries, 81-assertion test harness
utils/raceUtils.js183APP_VERSION, RACE_REGISTRY, storage, CSV, formatting/URL helpers
utils/gpxUtils.js110haversine, parseGpx, snapToRoute, calcPace, tile URL builder
components/components.jsx216DrumColumn, TimeOfDayDrum, ElevationProfile, LegMiniMap, NavStatusPill, DiffBadge, DeltaChip, RacePositionStrip, CountdownCard
components/LegDetailModal.jsx103Full leg detail sheet/panel (supports inline)
components/ReassignModal.jsx39Runner reassignment
components/AdjustPaceModal.jsx31Manual pace override drum
components/ObservedPaceAdjuster.jsx69GPS observation ±30s/mi adjustment
components/Icons.jsx1112 SVG icon components
data/legElevationProfiles.js~64 KBPre-computed elevation arrays per leg
data/races/htc_2026.json, rto_2026.jsonRace metadata — do not modify structure

Phone (src/phone/)

File~LinesPurpose
RelayTimer.jsx115App shell: tab bar, nav bar, race-switch dialog
screens/HomeScreen.jsx86Home tab (also used directly by iPad via @phone alias)
screens/ExchangeScreen.jsx100Exchange tab
screens/TimelineScreen.jsx43Timeline (modal overlay for leg detail)
screens/RunnersScreen.jsx26Runners tab
screens/GpxTrackerScreen.jsx242GPS tab
screens/SettingsScreen.jsx195Settings tab
screens/StartScreen.jsx29Pre-race screen
screens/LiveMapScreen.jsx472Leaflet map (also used directly by iPad via @phone alias)

iPad (src/ipad/)

File~LinesPurpose
RelayTimer.jsx130App shell: sidebar, top bar, screen switcher
screens/ExchangeScreen.jsx963-panel layout
screens/TimelineScreen.jsx57List + persistent detail panel
screens/RunnersScreen.jsx263-column grid
screens/GpxTrackerScreen.jsx246Side-by-side, map always visible
screens/SettingsScreen.jsx219Category/detail split
screens/StartScreen.jsx34Centered column, larger type

iPad has no HomeScreen.jsx or LiveMapScreen.jsx — both imported directly from @phone.

18

Version History

1.10.2
2026-07-05
iPad Phase 4 (partial): splash screens for 3 iPad sizes wired into 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.
1.10.1
2026-07-03
Fixed start-time clock bug (race showed immediately overdue on start — stale mount-time snapshot in 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.
1.10.0
iPad Phase 3 complete: GpxTrackerScreen, SettingsScreen, StartScreen real iPad layouts — last 3 @phone placeholders replaced.
1.9.0
iPad Phase 3 (partial): ExchangeScreen, TimelineScreen, RunnersScreen real iPad layouts; LegDetailModal gained inline prop.
1.8.3
Monorepo migration + iPad scaffold (Phase 1 + 2): restructured into src/phone/ + src/ipad/ + shared layers; added vite.config.ipad.js, iPad manifest/HTML/CSS scaffold with @phone placeholder screens.
1.8.1
2026-06-25
BL-42/BL-51: flicker verification, Exchange UI polish batch. See archived v1.8.1 snapshot for full detail.
1.8.0
2026-06-24
BL-50: monolith split — 1623-line RelayTimer.jsx split into 16 files.
1.7.0–1.7.8
forecastEngine consolidation, SW/PWA hardening, GPX/tile consolidation, GPS pace guardrails. See archived snapshots for full table.
Full detail for versions ≤1.8.1 is preserved in docs/Archive/APP_DOCUMENTATION_v1.8.1.md and related archived files.
End of documentation. This file, combined with the source files under relay-timer/src/, provides everything needed to rebuild RelayTimer v1.10.2 from scratch on both platforms.