From 90fb7c07d8758d0faa23a45dafaace18c3d411a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 07:25:40 +0000 Subject: [PATCH 1/5] Initial plan From 74e18496a00bb5ab4d4c8ac67990fa70899c5007 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 07:42:13 +0000 Subject: [PATCH 2/5] feat: add Race Split Calculator with pace band printing New feature for runners: enter a goal time and distance to get per-km or per-mile split targets for race day. - Even, negative, and positive split strategies - Printable pace band for wrist - Copy splits to clipboard - Race day pacing tips section - 18 unit tests for calculation logic - Added to navigation, landing page, and comparison table Co-authored-by: aleexwong <65264501+aleexwong@users.noreply.github.com> --- vite-project/src/App.tsx | 2 + .../src/components/layout/Landing.tsx | 59 ++ .../src/components/layout/SideNav.tsx | 1 + .../split-calculator/__tests__/utils.test.ts | 131 ++++ .../components/SplitCalculator.tsx | 572 ++++++++++++++++++ .../components/SplitTable.tsx | 97 +++ .../src/features/split-calculator/index.ts | 12 + .../src/features/split-calculator/types.ts | 61 ++ .../src/features/split-calculator/utils.ts | 175 ++++++ vite-project/src/pages/SplitCalculator.tsx | 12 + vite-project/vite.config.ts | 1 + 11 files changed, 1123 insertions(+) create mode 100644 vite-project/src/features/split-calculator/__tests__/utils.test.ts create mode 100644 vite-project/src/features/split-calculator/components/SplitCalculator.tsx create mode 100644 vite-project/src/features/split-calculator/components/SplitTable.tsx create mode 100644 vite-project/src/features/split-calculator/index.ts create mode 100644 vite-project/src/features/split-calculator/types.ts create mode 100644 vite-project/src/features/split-calculator/utils.ts create mode 100644 vite-project/src/pages/SplitCalculator.tsx diff --git a/vite-project/src/App.tsx b/vite-project/src/App.tsx index f08c8d1..5f7ff73 100644 --- a/vite-project/src/App.tsx +++ b/vite-project/src/App.tsx @@ -30,6 +30,7 @@ import Terms from "./pages/Terms"; import About from "./pages/About"; import DashboardV2 from "./pages/DashboardV2"; import { BlogList, BlogPost } from "./features/blog"; +import SplitCalculatorPage from "./pages/SplitCalculator"; import AuthGuard from "./features/auth/AuthGuard"; function App() { @@ -49,6 +50,7 @@ function App() { /> } /> } /> + } /> } /> } /> } /> diff --git a/vite-project/src/components/layout/Landing.tsx b/vite-project/src/components/layout/Landing.tsx index e058255..6b4b1e6 100644 --- a/vite-project/src/components/layout/Landing.tsx +++ b/vite-project/src/components/layout/Landing.tsx @@ -453,6 +453,37 @@ const FeatureSection = ({ )} + {badge === "Race Splits" && ( +
+
+ Half Marathon · 1:45:00 Goal + Even +
+
+
+ Mile 1 + 8:01 + 0:08:01 +
+
+ Mile 2 + 8:01 + 0:16:02 +
+
+ Mile 3 + 8:01 + 0:24:03 +
+
···
+
+ Finish + 0.1 mi + 1:45:00 +
+
+
+ )} @@ -699,6 +730,18 @@ const Comparison = () => { Rarely included + + + Race Splits + + + + {" "} + Printable Pace Band + + + Manual calculation + Account Required? @@ -859,6 +902,22 @@ export default function LandingPage() { imageSide="right" /> + + diff --git a/vite-project/src/components/layout/SideNav.tsx b/vite-project/src/components/layout/SideNav.tsx index 3f0a727..f815f49 100644 --- a/vite-project/src/components/layout/SideNav.tsx +++ b/vite-project/src/components/layout/SideNav.tsx @@ -10,6 +10,7 @@ export default function SideNav() { const links = [ { href: "/", label: "Home" }, { href: "/calculator", label: "Calculator" }, + { href: "/splits", label: "Race Splits" }, { href: "/fuel", label: "Fuel Planner" }, { href: "/elevation-finder", label: "Elevation Finder" }, { href: "/login", label: "Login" }, diff --git a/vite-project/src/features/split-calculator/__tests__/utils.test.ts b/vite-project/src/features/split-calculator/__tests__/utils.test.ts new file mode 100644 index 0000000..fbfd85e --- /dev/null +++ b/vite-project/src/features/split-calculator/__tests__/utils.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; +import { + timeToSeconds, + secondsToTimeString, + validateSplitInputs, + calculateSplits, +} from "../utils"; + +describe("timeToSeconds", () => { + it("converts hours, minutes, seconds to total seconds", () => { + expect(timeToSeconds("1", "30", "0")).toBe(5400); + expect(timeToSeconds("0", "25", "30")).toBe(1530); + expect(timeToSeconds("3", "59", "59")).toBe(14399); + }); + + it("handles empty strings as zero", () => { + expect(timeToSeconds("", "", "")).toBe(0); + expect(timeToSeconds("1", "", "")).toBe(3600); + }); +}); + +describe("secondsToTimeString", () => { + it("formats time with hours", () => { + expect(secondsToTimeString(5400)).toBe("1:30:00"); + expect(secondsToTimeString(14399)).toBe("3:59:59"); + }); + + it("formats time without hours", () => { + expect(secondsToTimeString(300)).toBe("5:00"); + expect(secondsToTimeString(90)).toBe("1:30"); + }); + + it("pads minutes and seconds", () => { + expect(secondsToTimeString(3661)).toBe("1:01:01"); + }); + + it("handles fractional seconds that round up to 60", () => { + // 59.7 seconds should become 1:00, not 0:60 + expect(secondsToTimeString(59.7)).toBe("1:00"); + // 6299.5 should be 1:45:00 not 1:44:60 + expect(secondsToTimeString(6299.5)).toBe("1:45:00"); + }); +}); + +describe("validateSplitInputs", () => { + it("accepts valid inputs", () => { + const result = validateSplitInputs("42.195", "3", "30", "0"); + expect(result.isValid).toBe(true); + expect(Object.keys(result.errors)).toHaveLength(0); + }); + + it("rejects empty distance", () => { + const result = validateSplitInputs("", "3", "30", "0"); + expect(result.isValid).toBe(false); + expect(result.errors.distance).toBeDefined(); + }); + + it("rejects zero time", () => { + const result = validateSplitInputs("10", "0", "0", "0"); + expect(result.isValid).toBe(false); + expect(result.errors.time).toBeDefined(); + }); + + it("rejects invalid time format", () => { + const result = validateSplitInputs("10", "0", "61", "0"); + expect(result.isValid).toBe(false); + expect(result.errors.time).toBeDefined(); + }); +}); + +describe("calculateSplits", () => { + it("generates correct number of splits for a 10K in km", () => { + const result = calculateSplits(3000, 10, "km", "even"); + expect(result.splits).toHaveLength(10); + expect(result.distance).toBe(10); + expect(result.units).toBe("km"); + }); + + it("generates a partial last split for marathon distance", () => { + // 42.195 km → 42 full splits + 1 partial + const result = calculateSplits(14400, 42.195, "km", "even"); + expect(result.splits).toHaveLength(43); + const last = result.splits[result.splits.length - 1]; + expect(last.isPartial).toBe(true); + }); + + it("even splits produce equal split times for whole distances", () => { + const result = calculateSplits(3000, 10, "km", "even"); + // All splits should be 5:00 (300 seconds each) + result.splits.forEach((split) => { + expect(split.splitTime).toBe("5:00"); + }); + }); + + it("negative splits: last split is faster than first", () => { + const result = calculateSplits(3000, 10, "km", "negative"); + const firstTime = result.splits[0].splitTime; + const lastTime = result.splits[result.splits.length - 1].splitTime; + // First split should be slower than last + expect(firstTime > lastTime).toBe(true); + }); + + it("positive splits: first split is faster than last", () => { + const result = calculateSplits(3000, 10, "km", "positive"); + const firstTime = result.splits[0].splitTime; + const lastTime = result.splits[result.splits.length - 1].splitTime; + // First split should be faster than last + expect(firstTime < lastTime).toBe(true); + }); + + it("cumulative time of last split equals total time", () => { + const totalSeconds = 3600; // 1 hour + const result = calculateSplits(totalSeconds, 10, "km", "even"); + expect(result.totalTime).toBe("1:00:00"); + const lastSplit = result.splits[result.splits.length - 1]; + expect(lastSplit.cumulativeTime).toBe("1:00:00"); + }); + + it("works with miles unit", () => { + const result = calculateSplits(5400, 13.1, "miles", "even"); + expect(result.splits.length).toBe(14); // 13 full + 1 partial + expect(result.units).toBe("miles"); + expect(result.averagePace).toContain("/mi"); + }); + + it("preserves strategy in results", () => { + expect(calculateSplits(3000, 10, "km", "even").strategy).toBe("even"); + expect(calculateSplits(3000, 10, "km", "negative").strategy).toBe("negative"); + expect(calculateSplits(3000, 10, "km", "positive").strategy).toBe("positive"); + }); +}); diff --git a/vite-project/src/features/split-calculator/components/SplitCalculator.tsx b/vite-project/src/features/split-calculator/components/SplitCalculator.tsx new file mode 100644 index 0000000..7a604b7 --- /dev/null +++ b/vite-project/src/features/split-calculator/components/SplitCalculator.tsx @@ -0,0 +1,572 @@ +/** + * Split Calculator - Main Orchestrator + * Generates per-km or per-mile race splits for race day + */ + +import { useState, useRef } from "react"; +import { Helmet } from "react-helmet-async"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Info, ChevronDown, ChevronUp, Printer, Copy } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import ReactGA from "react-ga4"; + +import { + type SplitInputs, + type SplitResults, + type SplitStrategy, + type FormErrors, + type DistanceUnit, + PRESET_RACES, + STRATEGY_INFO, +} from "../types"; +import { + timeToSeconds, + validateSplitInputs, + calculateSplits, +} from "../utils"; +import { SplitTable } from "./SplitTable"; + +const initialFormState: SplitInputs = { + distance: "", + units: "km", + hours: "", + minutes: "", + seconds: "", + strategy: "even", +}; + +export function SplitCalculator() { + const { toast } = useToast(); + const printRef = useRef(null); + + // Form state + const [inputs, setInputs] = useState(initialFormState); + const [results, setResults] = useState(null); + const [errors, setErrors] = useState({}); + + // UI state + const [showInfo, setShowInfo] = useState(false); + const [showTips, setShowTips] = useState(false); + + ReactGA.event({ + category: "Split Calculator", + action: "Page View", + label: "User opened the Split Calculator", + }); + + const handleInputChange = (e: { target: { name: string; value: string } }) => { + const { name, value } = e.target; + + if (["hours", "minutes", "seconds"].includes(name)) { + const numValue = value.replace(/\D/g, ""); + setInputs((prev) => ({ ...prev, [name]: numValue.slice(0, 2) })); + return; + } + + setInputs((prev) => ({ ...prev, [name]: value })); + + if (errors[name as keyof FormErrors]) { + setErrors((prev) => ({ ...prev, [name]: undefined })); + } + }; + + const handlePreset = (distanceKm: number, distanceMi: number) => { + const distance = inputs.units === "km" ? distanceKm : distanceMi; + setInputs((prev) => ({ ...prev, distance: distance.toString() })); + setErrors({}); + }; + + const handleUnitToggle = (unit: DistanceUnit) => { + setInputs((prev) => ({ ...prev, units: unit })); + setResults(null); + }; + + const handleStrategyChange = (strategy: SplitStrategy) => { + setInputs((prev) => ({ ...prev, strategy })); + if (results) { + // Recalculate with new strategy + const totalSeconds = timeToSeconds(inputs.hours, inputs.minutes, inputs.seconds); + const dist = parseFloat(inputs.distance); + setResults(calculateSplits(totalSeconds, dist, inputs.units, strategy)); + } + }; + + const handleCalculate = () => { + const { isValid, errors: validationErrors } = validateSplitInputs( + inputs.distance, + inputs.hours, + inputs.minutes, + inputs.seconds + ); + + if (!isValid) { + setErrors(validationErrors); + toast({ + title: "Validation Error", + description: "Please check the form for errors.", + variant: "destructive", + }); + return; + } + + const totalSeconds = timeToSeconds(inputs.hours, inputs.minutes, inputs.seconds); + const dist = parseFloat(inputs.distance); + const splitResults = calculateSplits(totalSeconds, dist, inputs.units, inputs.strategy); + + setResults(splitResults); + + toast({ + title: "Splits Calculated! 🏁", + description: `${splitResults.splits.length} splits generated.`, + duration: 3000, + }); + + ReactGA.event({ + category: "Split Calculator", + action: "Calculated Splits", + label: `${inputs.distance}${inputs.units} - ${inputs.strategy}`, + }); + }; + + const handleEdit = () => { + setResults(null); + }; + + const handleCopy = async () => { + if (!results) return; + + const header = `Race Splits: ${results.distance} ${results.units} in ${results.totalTime} (${STRATEGY_INFO[results.strategy].label})\n`; + const divider = "-".repeat(55) + "\n"; + let text = header + divider; + + results.splits.forEach((row) => { + text += `${row.distanceLabel.padEnd(12)} ${row.splitTime.padStart(8)} ${row.splitPace.padStart(10)} ${row.cumulativeTime.padStart(10)}\n`; + }); + + text += divider; + text += `Average Pace: ${results.averagePace}\n`; + + try { + await navigator.clipboard.writeText(text); + toast({ title: "Copied to clipboard! 📋" }); + } catch { + toast({ title: "Failed to copy", variant: "destructive" }); + } + }; + + const handlePrint = () => { + if (!printRef.current) return; + + const printWindow = window.open("", "_blank"); + if (!printWindow) { + toast({ title: "Please allow popups to print", variant: "destructive" }); + return; + } + + const strategyLabel = STRATEGY_INFO[results!.strategy].label; + + printWindow.document.write(` + + + + Pace Band - ${results!.distance} ${results!.units} + + + +

🏁 Pace Band

+
${results!.distance} ${results!.units} · Goal: ${results!.totalTime} · ${strategyLabel}
+ + + + + + ${results!.splits + .map( + (row) => + ` + + + + + ` + ) + .join("")} + +
SplitTimePaceElapsed
${row.distanceLabel}${row.splitTime}${row.splitPace}${row.cumulativeTime}
+ + + + `); + + printWindow.document.close(); + printWindow.print(); + }; + + return ( + <> + + Race Split Calculator – Pace Band Generator | TrainPace + + + + +
+
+ {/* Header */} +
+

+ 🏁 Race Splits +

+ +
+ + {showInfo && ( + + +

How It Works 🏁

+

+ Enter your goal time and race distance to get split-by-split + target times you can follow on race day. +

+
    +
  • Choose even, negative, or positive split strategies
  • +
  • See per-mile or per-km splits with elapsed time
  • +
  • Print a compact pace band for your wrist
  • +
  • Copy splits to share with your running group
  • +
+
+
+ )} + + {/* Main Content */} +
+ {!results ? ( + /* Form */ + + + {/* Distance Presets */} +
+ +
+ {PRESET_RACES.map((race) => ( + + ))} +
+ + {/* Custom Distance + Unit Toggle */} +
+ +
+ + +
+
+ {errors.distance && ( +

{errors.distance}

+ )} +
+ + {/* Goal Time */} +
+ +
+
+ + + hrs + +
+ : +
+ + + min + +
+ : +
+ + + sec + +
+
+ {errors.time && ( +

{errors.time}

+ )} +
+ + {/* Pacing Strategy */} +
+ +
+ {( + Object.entries(STRATEGY_INFO) as [ + SplitStrategy, + (typeof STRATEGY_INFO)[SplitStrategy], + ][] + ).map(([key, info]) => ( + + ))} +
+
+ + {/* Calculate Button */} + +
+
+ ) : ( + /* Results */ +
+ + +
+

+ Your Race Splits +

+
+ + +
+
+ + + + {/* Strategy Switcher */} +
+

+ Try a different strategy: +

+
+ {( + Object.entries(STRATEGY_INFO) as [ + SplitStrategy, + (typeof STRATEGY_INFO)[SplitStrategy], + ][] + ).map(([key, info]) => ( + + ))} +
+
+
+
+ +
+ +
+
+ )} +
+ + {/* Race Day Tips */} +
+ + + {showTips && ( + + +
    +
  • + Start conservative: The first mile always + feels easy — trust your splits, not your adrenaline. +
  • +
  • + Check at every mile marker: Glance at your + elapsed time and compare to your pace band. +
  • +
  • + Don't chase lost time: If you're 15 seconds + behind, spread the recovery over the next 3–4 splits. +
  • +
  • + Hills change everything: Expect to lose 10–15 + seconds on uphills and gain it back on downhills. +
  • +
  • + The last 10K is a new race: In a marathon, + miles 20–26 are where pacing discipline pays off. +
  • +
  • + Print your pace band: Tape it to your wrist + or safety-pin it to your bib. Don't rely on your watch alone. +
  • +
+
+
+ )} +
+
+
+ + ); +} diff --git a/vite-project/src/features/split-calculator/components/SplitTable.tsx b/vite-project/src/features/split-calculator/components/SplitTable.tsx new file mode 100644 index 0000000..8334337 --- /dev/null +++ b/vite-project/src/features/split-calculator/components/SplitTable.tsx @@ -0,0 +1,97 @@ +/** + * SplitTable - Displays the split breakdown in a clean table + */ + +import type { SplitResults } from "../types"; +import { STRATEGY_INFO } from "../types"; + +interface SplitTableProps { + results: SplitResults; +} + +export function SplitTable({ results }: SplitTableProps) { + const strategyInfo = STRATEGY_INFO[results.strategy]; + + return ( +
+ {/* Summary Header */} +
+
+

Total Time

+

{results.totalTime}

+
+
+

Avg Pace

+

+ {results.averagePace} +

+
+
+

Strategy

+

+ {strategyInfo.label} +

+
+
+ + {/* Split Table */} +
+ + + + + + + + + + + + {results.splits.map((row) => ( + + + + + + + + ))} + +
+ Split + + Distance + + Split Time + + Pace + + Elapsed +
+ {row.split} + + {row.distanceLabel} + + {row.splitTime} + + {row.splitPace} + + {row.cumulativeTime} +
+
+ + {/* Partial Split Note */} + {results.splits.some((s) => s.isPartial) && ( +

+ * Final split is a partial distance. Pace shown is per full{" "} + {results.units === "km" ? "kilometer" : "mile"}. +

+ )} +
+ ); +} diff --git a/vite-project/src/features/split-calculator/index.ts b/vite-project/src/features/split-calculator/index.ts new file mode 100644 index 0000000..7a80f05 --- /dev/null +++ b/vite-project/src/features/split-calculator/index.ts @@ -0,0 +1,12 @@ +/** + * Split Calculator Feature - Public Exports + */ + +export { SplitCalculator } from "./components/SplitCalculator"; +export type { + SplitInputs, + SplitResults, + SplitRow, + SplitStrategy, + DistanceUnit, +} from "./types"; diff --git a/vite-project/src/features/split-calculator/types.ts b/vite-project/src/features/split-calculator/types.ts new file mode 100644 index 0000000..30e187e --- /dev/null +++ b/vite-project/src/features/split-calculator/types.ts @@ -0,0 +1,61 @@ +/** + * Split Calculator - Core Types & Constants + */ + +export type DistanceUnit = "km" | "miles"; + +export type SplitStrategy = "even" | "negative" | "positive"; + +export const PRESET_RACES = [ + { name: "Marathon", distanceKm: 42.195, distanceMi: 26.2 }, + { name: "Half Marathon", distanceKm: 21.0975, distanceMi: 13.1 }, + { name: "10K", distanceKm: 10, distanceMi: 6.2 }, + { name: "5K", distanceKm: 5, distanceMi: 3.1 }, +] as const; + +export const STRATEGY_INFO: Record = { + even: { + label: "Even Splits", + description: "Same pace throughout — the gold standard for experienced runners.", + }, + negative: { + label: "Negative Splits", + description: "Start conservatively, finish strong — recommended for PRs.", + }, + positive: { + label: "Positive Splits", + description: "Start fast, slow down later — common for beginners, plan for it.", + }, +}; + +export interface SplitInputs { + distance: string; + units: DistanceUnit; + hours: string; + minutes: string; + seconds: string; + strategy: SplitStrategy; +} + +export interface SplitRow { + split: number; + distanceLabel: string; + splitTime: string; + splitPace: string; + cumulativeTime: string; + isPartial: boolean; +} + +export interface SplitResults { + splits: SplitRow[]; + totalTime: string; + averagePace: string; + distance: number; + units: DistanceUnit; + strategy: SplitStrategy; +} + +export interface FormErrors { + distance?: string; + time?: string; +} diff --git a/vite-project/src/features/split-calculator/utils.ts b/vite-project/src/features/split-calculator/utils.ts new file mode 100644 index 0000000..6f0f6de --- /dev/null +++ b/vite-project/src/features/split-calculator/utils.ts @@ -0,0 +1,175 @@ +/** + * Split Calculator Utilities + * Pure functions for split calculations + */ + +import type { + SplitRow, + SplitResults, + SplitStrategy, + DistanceUnit, +} from "./types"; + +/** + * Convert time inputs to total seconds + */ +export function timeToSeconds(h: string, m: string, s: string): number { + return ( + parseInt(h || "0") * 3600 + parseInt(m || "0") * 60 + parseInt(s || "0") + ); +} + +/** + * Convert seconds to formatted time string (H:MM:SS or M:SS) + */ +export function secondsToTimeString(totalSeconds: number): string { + // Round to nearest second first, then decompose + const rounded = Math.round(totalSeconds); + const hours = Math.floor(rounded / 3600); + const minutes = Math.floor((rounded % 3600) / 60); + const seconds = rounded % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}`; + } + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +/** + * Validate split calculator inputs + */ +export function validateSplitInputs( + distance: string, + hours: string, + minutes: string, + seconds: string +): { isValid: boolean; errors: Record } { + const errors: Record = {}; + + if (!distance) { + errors.distance = "Distance is required"; + } else if (isNaN(Number(distance)) || parseFloat(distance) <= 0) { + errors.distance = "Please enter a valid distance"; + } + + const h = parseInt(hours || "0"); + const m = parseInt(minutes || "0"); + const s = parseInt(seconds || "0"); + + if (h === 0 && m === 0 && s === 0) { + errors.time = "Please enter a goal time"; + } + + if (m >= 60 || s >= 60) { + errors.time = "Invalid time format"; + } + + return { + isValid: Object.keys(errors).length === 0, + errors, + }; +} + +/** + * Calculate pace adjustment factor per split for a given strategy. + * + * - even: all splits get factor 1.0 + * - negative: first half ~3% slower, second half ~3% faster + * - positive: first half ~3% faster, second half ~3% slower + * + * Factors are normalised so the total time stays the same as even pacing. + */ +function getStrategyFactors( + totalSplits: number, + strategy: SplitStrategy +): number[] { + if (strategy === "even") { + return Array(totalSplits).fill(1); + } + + const factors: number[] = []; + const midpoint = totalSplits / 2; + const maxAdjust = 0.03; // 3% swing + + for (let i = 0; i < totalSplits; i++) { + // Linear interpolation from slow→fast (negative) or fast→slow (positive) + const t = (i - midpoint + 0.5) / midpoint; // -1 … +1 + const adjust = strategy === "negative" ? -t * maxAdjust : t * maxAdjust; + factors.push(1 + adjust); + } + + // Normalise so sum(factors) === totalSplits (keeps total time identical) + const sum = factors.reduce((a, b) => a + b, 0); + const scale = totalSplits / sum; + return factors.map((f) => f * scale); +} + +/** + * Calculate race splits + */ +export function calculateSplits( + totalTimeSeconds: number, + distance: number, + units: DistanceUnit, + strategy: SplitStrategy +): SplitResults { + const fullSplits = Math.floor(distance); + const remainder = distance - fullSplits; + const totalSplits = remainder > 0.01 ? fullSplits + 1 : fullSplits; + + const basePacePerUnit = totalTimeSeconds / distance; + const factors = getStrategyFactors(totalSplits, strategy); + + // Distribute time proportionally (partial last split gets proportional time) + const splitDistances: number[] = []; + for (let i = 0; i < totalSplits; i++) { + if (i === totalSplits - 1 && remainder > 0.01) { + splitDistances.push(remainder); + } else { + splitDistances.push(1); + } + } + + // Calculate raw times per split (factor × basePace × distance of that split) + const rawTimes = splitDistances.map( + (d, i) => factors[i] * basePacePerUnit * d + ); + + // Small correction: ensure the sum equals totalTimeSeconds exactly + const rawSum = rawTimes.reduce((a, b) => a + b, 0); + const correction = totalTimeSeconds / rawSum; + const splitTimes = rawTimes.map((t) => t * correction); + + const unitLabel = units === "km" ? "km" : "mi"; + let cumulative = 0; + let cumulativeDistance = 0; + + const splits: SplitRow[] = splitTimes.map((time, i) => { + cumulative += time; + cumulativeDistance += splitDistances[i]; + const isPartial = i === totalSplits - 1 && remainder > 0.01; + const pacePerFullUnit = isPartial ? time / splitDistances[i] : time; + + return { + split: i + 1, + distanceLabel: isPartial + ? `${cumulativeDistance.toFixed(2)} ${unitLabel}` + : `${cumulativeDistance} ${unitLabel}`, + splitTime: secondsToTimeString(time), + splitPace: `${secondsToTimeString(pacePerFullUnit)}/${unitLabel}`, + cumulativeTime: secondsToTimeString(cumulative), + isPartial, + }; + }); + + return { + splits, + totalTime: secondsToTimeString(totalTimeSeconds), + averagePace: `${secondsToTimeString(basePacePerUnit)}/${unitLabel}`, + distance, + units, + strategy, + }; +} diff --git a/vite-project/src/pages/SplitCalculator.tsx b/vite-project/src/pages/SplitCalculator.tsx new file mode 100644 index 0000000..a49a2d9 --- /dev/null +++ b/vite-project/src/pages/SplitCalculator.tsx @@ -0,0 +1,12 @@ +/** + * Race Split Calculator Page + * Thin page wrapper that renders the split calculator feature + */ + +import { SplitCalculator } from "@/features/split-calculator"; + +const SplitCalculatorPage = () => { + return ; +}; + +export default SplitCalculatorPage; diff --git a/vite-project/vite.config.ts b/vite-project/vite.config.ts index 904cd92..209fb30 100644 --- a/vite-project/vite.config.ts +++ b/vite-project/vite.config.ts @@ -12,6 +12,7 @@ const prerenderedRoutes = [ "/calculator", "/fuel", "/elevationfinder", + "/splits", "/race", // Programmatic SEO routes ...getAllSeoPaths(), From ccf3fa616c8bef0e0d60574ea831238ffa2e26ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 07:45:00 +0000 Subject: [PATCH 3/5] fix: move ReactGA page view tracking to useEffect hook Co-authored-by: aleexwong <65264501+aleexwong@users.noreply.github.com> --- .../components/SplitCalculator.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/vite-project/src/features/split-calculator/components/SplitCalculator.tsx b/vite-project/src/features/split-calculator/components/SplitCalculator.tsx index 7a604b7..29b5d87 100644 --- a/vite-project/src/features/split-calculator/components/SplitCalculator.tsx +++ b/vite-project/src/features/split-calculator/components/SplitCalculator.tsx @@ -3,7 +3,7 @@ * Generates per-km or per-mile race splits for race day */ -import { useState, useRef } from "react"; +import { useState, useRef, useEffect } from "react"; import { Helmet } from "react-helmet-async"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -49,11 +49,14 @@ export function SplitCalculator() { const [showInfo, setShowInfo] = useState(false); const [showTips, setShowTips] = useState(false); - ReactGA.event({ - category: "Split Calculator", - action: "Page View", - label: "User opened the Split Calculator", - }); + // Track page view once on mount + useEffect(() => { + ReactGA.event({ + category: "Split Calculator", + action: "Page View", + label: "User opened the Split Calculator", + }); + }, []); const handleInputChange = (e: { target: { name: string; value: string } }) => { const { name, value } = e.target; From 196a60dcdba08b2acaff1034d6fb86d0cb0f2f36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:07:10 +0000 Subject: [PATCH 4/5] fix: replace shadcn Button with native button on splits page for consistent hover styles The shadcn Button component's built-in hover styles (hover:bg-primary/90, hover:bg-accent) conflicted with custom Tailwind classes, causing bad hover behavior. Replaced with native + ) : ( @@ -456,22 +455,18 @@ export function SplitCalculator() { Your Race Splits
- - +
@@ -507,13 +502,12 @@ export function SplitCalculator() {
- +
)} From a663bfd894d464f2e88e2facc3649f8f84e40ee8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:31:16 +0000 Subject: [PATCH 5/5] fix: km/mi toggle on splits page now converts distance with animated toggle The km/mi toggle only changed the unit label without converting the distance value. Selecting "Half Marathon" (21.0975 km) and toggling to miles would leave distance as 21.0975 treated as miles. - Add convertDistance utility with smart snapping to common race distances - Update handleUnitToggle to convert distance when switching units - Replace plain buttons with animated sliding toggle (matches /calculator) - Add 4 test cases for convertDistance function Co-authored-by: aleexwong <65264501+aleexwong@users.noreply.github.com> --- .../split-calculator/__tests__/utils.test.ts | 31 ++++++++++ .../components/SplitCalculator.tsx | 60 ++++++++++++------- .../src/features/split-calculator/utils.ts | 35 +++++++++++ 3 files changed, 105 insertions(+), 21 deletions(-) diff --git a/vite-project/src/features/split-calculator/__tests__/utils.test.ts b/vite-project/src/features/split-calculator/__tests__/utils.test.ts index fbfd85e..afcaaec 100644 --- a/vite-project/src/features/split-calculator/__tests__/utils.test.ts +++ b/vite-project/src/features/split-calculator/__tests__/utils.test.ts @@ -4,6 +4,7 @@ import { secondsToTimeString, validateSplitInputs, calculateSplits, + convertDistance, } from "../utils"; describe("timeToSeconds", () => { @@ -129,3 +130,33 @@ describe("calculateSplits", () => { expect(calculateSplits(3000, 10, "km", "positive").strategy).toBe("positive"); }); }); + +describe("convertDistance", () => { + it("returns same distance when units match", () => { + expect(convertDistance(10, "km", "km")).toBe(10); + expect(convertDistance(6.2, "miles", "miles")).toBe(6.2); + }); + + it("converts km to miles with smart snapping", () => { + // 10 km → ~6.21 mi, snaps to common 6.2 + expect(convertDistance(10, "km", "miles")).toBe(6.2); + // 42.195 km → ~26.22 mi, snaps to common 26.2 + expect(convertDistance(42.195, "km", "miles")).toBe(26.2); + // 21.0975 km → ~13.11 mi, snaps to common 13.1 + expect(convertDistance(21.0975, "km", "miles")).toBe(13.1); + }); + + it("converts miles to km with smart snapping", () => { + // 6.2 mi → ~9.98 km, snaps to common 10 + expect(convertDistance(6.2, "miles", "km")).toBe(10); + // 26.2 mi → ~42.16 km, snaps to common 42.2 + expect(convertDistance(26.2, "miles", "km")).toBe(42.2); + // 13.1 mi → ~21.08 km, snaps to common 21.1 + expect(convertDistance(13.1, "miles", "km")).toBe(21.1); + }); + + it("rounds to 1 decimal for non-common distances", () => { + // 15 km → ~9.32 mi, no common race near this + expect(convertDistance(15, "km", "miles")).toBe(9.3); + }); +}); diff --git a/vite-project/src/features/split-calculator/components/SplitCalculator.tsx b/vite-project/src/features/split-calculator/components/SplitCalculator.tsx index fb86088..4d55d6b 100644 --- a/vite-project/src/features/split-calculator/components/SplitCalculator.tsx +++ b/vite-project/src/features/split-calculator/components/SplitCalculator.tsx @@ -23,6 +23,7 @@ import { timeToSeconds, validateSplitInputs, calculateSplits, + convertDistance, } from "../utils"; import { SplitTable } from "./SplitTable"; @@ -80,7 +81,20 @@ export function SplitCalculator() { }; const handleUnitToggle = (unit: DistanceUnit) => { - setInputs((prev) => ({ ...prev, units: unit })); + if (unit === inputs.units) return; + + // Convert existing distance if there is one + if (inputs.distance && !isNaN(parseFloat(inputs.distance))) { + const currentDistance = parseFloat(inputs.distance); + const convertedDistance = convertDistance(currentDistance, inputs.units, unit); + setInputs((prev) => ({ + ...prev, + units: unit, + distance: convertedDistance.toString(), + })); + } else { + setInputs((prev) => ({ ...prev, units: unit })); + } setResults(null); }; @@ -309,27 +323,31 @@ export function SplitCalculator() { errors.distance ? "border-red-400" : "border-gray-300" }`} /> -
- - + /> +
+
+ KM +
+
+ MI +
+
{errors.distance && ( diff --git a/vite-project/src/features/split-calculator/utils.ts b/vite-project/src/features/split-calculator/utils.ts index 6f0f6de..4334393 100644 --- a/vite-project/src/features/split-calculator/utils.ts +++ b/vite-project/src/features/split-calculator/utils.ts @@ -10,6 +10,41 @@ import type { DistanceUnit, } from "./types"; +/** + * Common race distances in both units for smart snapping + */ +const COMMON_RACES = { + km: [0.8, 1, 5, 10, 21.1, 42.2], + miles: [0.5, 1, 3.1, 6.2, 13.1, 26.2], +}; + +/** + * Convert distance between km and miles with smart snapping. + * Snaps to nearest common race distance if close, otherwise rounds to 1 decimal. + */ +export function convertDistance( + distance: number, + fromUnit: DistanceUnit, + toUnit: DistanceUnit +): number { + if (fromUnit === toUnit) return distance; + + const conversionFactor = fromUnit === "km" ? 0.621371 : 1.60934; + const converted = distance * conversionFactor; + + // Snap to common race distance if within 2% + const commonRaces = toUnit === "km" ? COMMON_RACES.km : COMMON_RACES.miles; + for (const raceDistance of commonRaces) { + const percentDiff = + (Math.abs(converted - raceDistance) / raceDistance) * 100; + if (percentDiff < 2) { + return raceDistance; + } + } + + return Math.round(converted * 10) / 10; +} + /** * Convert time inputs to total seconds */