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" && (
+
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}
+
+
+ | Split | Time | Pace | Elapsed |
+
+
+ ${results!.splits
+ .map(
+ (row) =>
+ `
+ | ${row.distanceLabel} |
+ ${row.splitTime} |
+ ${row.splitPace} |
+ ${row.cumulativeTime} |
+ `
+ )
+ .join("")}
+
+
+
+
+
+ `);
+
+ 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 */}
+
+
+
+
+ |
+ Split
+ |
+
+ Distance
+ |
+
+ Split Time
+ |
+
+ Pace
+ |
+
+ Elapsed
+ |
+
+
+
+ {results.splits.map((row) => (
+
+ |
+ {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
-
Copy
-
-
+
Pace Band
-
+
@@ -507,13 +502,12 @@ export function SplitCalculator() {
-
← Change Settings
-
+
)}
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"
}`}
/>
-
- handleUnitToggle("km")}
- className={`px-4 py-3 text-sm font-medium transition-all ${
- inputs.units === "km"
- ? "bg-blue-600 text-white"
- : "text-gray-600 hover:bg-gray-200"
+ handleUnitToggle(inputs.units === "km" ? "miles" : "km")}
+ >
+
- km
-
- handleUnitToggle("miles")}
- className={`px-4 py-3 text-sm font-medium transition-all ${
- inputs.units === "miles"
- ? "bg-blue-600 text-white"
- : "text-gray-600 hover:bg-gray-200"
- }`}
- >
- 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
*/
|