Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions vite-project/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -49,6 +50,7 @@ function App() {
/>
<Route path="/fuel" element={<FuelPlannerV2 />} />
<Route path="/fuel/:seoSlug" element={<FuelSeoLanding />} />
<Route path="/splits" element={<SplitCalculatorPage />} />
<Route path="/race" element={<RaceIndex />} />
<Route path="/race/:raceSlug" element={<RaceSeoLanding />} />
<Route path="/elevation-finder" element={<ElevationPage />} />
Expand Down
59 changes: 59 additions & 0 deletions vite-project/src/components/layout/Landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,37 @@
</div>
</div>
)}
{badge === "Race Splits" && (
<div className="space-y-3">
<div className="flex justify-between items-center pb-2 border-b border-slate-100">
<span className="text-xs text-slate-500">Half Marathon · 1:45:00 Goal</span>
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded font-medium">Even</span>
</div>
<div className="space-y-1.5 text-sm">
<div className="flex justify-between items-center">
<span className="text-slate-600">Mile 1</span>
<span className="font-mono text-slate-400 text-xs">8:01</span>
<span className="font-mono font-semibold text-blue-700 text-xs">0:08:01</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Mile 2</span>
<span className="font-mono text-slate-400 text-xs">8:01</span>
<span className="font-mono font-semibold text-blue-700 text-xs">0:16:02</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Mile 3</span>
<span className="font-mono text-slate-400 text-xs">8:01</span>
<span className="font-mono font-semibold text-blue-700 text-xs">0:24:03</span>
</div>
<div className="text-center text-slate-400 text-xs">···</div>
<div className="flex justify-between items-center bg-emerald-50 rounded px-1 py-0.5">
<span className="text-emerald-700 font-medium">Finish</span>
<span className="font-mono text-emerald-400 text-xs">0.1 mi</span>
<span className="font-mono font-bold text-emerald-700 text-xs">1:45:00</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
Expand Down Expand Up @@ -699,6 +730,18 @@
</td>
<td className="p-4 text-slate-500">Rarely included</td>
</tr>
<tr>
<td className="p-4 font-medium text-slate-900">
Race Splits
</td>
<td className="p-4 bg-emerald-50/30 border-l border-r border-emerald-100">
<span className="flex items-center">
<Check className="w-4 h-4 text-emerald-500 mr-2" />{" "}
Printable Pace Band
</span>
</td>
<td className="p-4 text-slate-500">Manual calculation</td>
</tr>
<tr>
<td className="p-4 font-medium text-slate-900">
Account Required?
Expand Down Expand Up @@ -859,6 +902,22 @@
imageSide="right"
/>

<FeatureSection
badge="Race Splits"
title="Know Your Target at Every Mile Marker"
subtitle="Enter your goal finish time and get split-by-split pacing for race day. Print a pace band for your wrist."
icon={Activity}

Check warning on line 909 in vite-project/src/components/layout/Landing.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/components/layout/Landing.tsx#L909

Unsafe assignment of an error typed value.
features={[
"Per-mile or per-km split targets",
"Even, negative, or positive pacing strategies",
"Printable pace band for race day",
"Cumulative elapsed time at each split",
]}
cta="Build My Pace Band"
ctaRoute="/splits"
imageSide="left"
/>

<FounderStory />
<Comparison />

Expand Down
1 change: 1 addition & 0 deletions vite-project/src/components/layout/SideNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
162 changes: 162 additions & 0 deletions vite-project/src/features/split-calculator/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { describe, it, expect } from "vitest";
import {
timeToSeconds,
secondsToTimeString,
validateSplitInputs,
calculateSplits,
convertDistance,
} from "../utils";

describe("timeToSeconds", () => {

Check warning on line 10 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L10

Unsafe call of an `error` type typed value.
it("converts hours, minutes, seconds to total seconds", () => {

Check warning on line 11 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L11

Unsafe call of an `error` type typed value.
expect(timeToSeconds("1", "30", "0")).toBe(5400);

Check warning on line 12 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L12

Unsafe call of an `error` type typed value.

Check warning on line 12 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L12

Unsafe member access .toBe on an `error` typed value.
expect(timeToSeconds("0", "25", "30")).toBe(1530);

Check warning on line 13 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L13

Unsafe call of an `error` type typed value.

Check warning on line 13 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L13

Unsafe member access .toBe on an `error` typed value.
expect(timeToSeconds("3", "59", "59")).toBe(14399);

Check warning on line 14 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L14

Unsafe call of an `error` type typed value.

Check warning on line 14 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L14

Unsafe member access .toBe on an `error` typed value.
});

it("handles empty strings as zero", () => {

Check warning on line 17 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L17

Unsafe call of an `error` type typed value.
expect(timeToSeconds("", "", "")).toBe(0);

Check warning on line 18 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L18

Unsafe call of an `error` type typed value.

Check warning on line 18 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L18

Unsafe member access .toBe on an `error` typed value.
expect(timeToSeconds("1", "", "")).toBe(3600);

Check warning on line 19 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L19

Unsafe call of an `error` type typed value.

Check warning on line 19 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L19

Unsafe member access .toBe on an `error` typed value.
});
});

describe("secondsToTimeString", () => {

Check warning on line 23 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L23

Unsafe call of an `error` type typed value.
it("formats time with hours", () => {

Check warning on line 24 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L24

Unsafe call of an `error` type typed value.
expect(secondsToTimeString(5400)).toBe("1:30:00");

Check warning on line 25 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L25

Unsafe call of an `error` type typed value.

Check warning on line 25 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L25

Unsafe member access .toBe on an `error` typed value.
expect(secondsToTimeString(14399)).toBe("3:59:59");

Check warning on line 26 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L26

Unsafe call of an `error` type typed value.

Check warning on line 26 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L26

Unsafe member access .toBe on an `error` typed value.
});

it("formats time without hours", () => {

Check warning on line 29 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L29

Unsafe call of an `error` type typed value.
expect(secondsToTimeString(300)).toBe("5:00");

Check warning on line 30 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L30

Unsafe call of an `error` type typed value.

Check warning on line 30 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L30

Unsafe member access .toBe on an `error` typed value.
expect(secondsToTimeString(90)).toBe("1:30");

Check warning on line 31 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L31

Unsafe call of an `error` type typed value.

Check warning on line 31 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L31

Unsafe member access .toBe on an `error` typed value.
});

it("pads minutes and seconds", () => {

Check warning on line 34 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L34

Unsafe call of an `error` type typed value.
expect(secondsToTimeString(3661)).toBe("1:01:01");

Check warning on line 35 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L35

Unsafe call of an `error` type typed value.

Check warning on line 35 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L35

Unsafe member access .toBe on an `error` typed value.
});

it("handles fractional seconds that round up to 60", () => {

Check warning on line 38 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L38

Unsafe call of an `error` type typed value.
// 59.7 seconds should become 1:00, not 0:60
expect(secondsToTimeString(59.7)).toBe("1:00");

Check warning on line 40 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L40

Unsafe call of an `error` type typed value.

Check warning on line 40 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L40

Unsafe member access .toBe on an `error` typed value.
// 6299.5 should be 1:45:00 not 1:44:60
expect(secondsToTimeString(6299.5)).toBe("1:45:00");

Check warning on line 42 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L42

Unsafe call of an `error` type typed value.

Check warning on line 42 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L42

Unsafe member access .toBe on an `error` typed value.
});
});

describe("validateSplitInputs", () => {

Check warning on line 46 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L46

Unsafe call of an `error` type typed value.
it("accepts valid inputs", () => {

Check warning on line 47 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L47

Unsafe call of an `error` type typed value.
const result = validateSplitInputs("42.195", "3", "30", "0");
expect(result.isValid).toBe(true);

Check warning on line 49 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L49

Unsafe call of an `error` type typed value.

Check warning on line 49 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L49

Unsafe member access .toBe on an `error` typed value.
expect(Object.keys(result.errors)).toHaveLength(0);

Check warning on line 50 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L50

Unsafe call of an `error` type typed value.

Check warning on line 50 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L50

Unsafe member access .toHaveLength on an `error` typed value.
});

it("rejects empty distance", () => {

Check warning on line 53 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L53

Unsafe call of an `error` type typed value.
const result = validateSplitInputs("", "3", "30", "0");
expect(result.isValid).toBe(false);

Check warning on line 55 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L55

Unsafe call of an `error` type typed value.

Check warning on line 55 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L55

Unsafe member access .toBe on an `error` typed value.
expect(result.errors.distance).toBeDefined();

Check warning on line 56 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L56

Unsafe call of an `error` type typed value.

Check warning on line 56 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L56

Unsafe member access .toBeDefined on an `error` typed value.
});

it("rejects zero time", () => {

Check warning on line 59 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L59

Unsafe call of an `error` type typed value.
const result = validateSplitInputs("10", "0", "0", "0");
expect(result.isValid).toBe(false);
expect(result.errors.time).toBeDefined();

Check warning on line 62 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L62

Unsafe call of an `error` type typed value.

Check warning on line 62 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L62

Unsafe member access .toBeDefined on an `error` typed value.
});

it("rejects invalid time format", () => {

Check warning on line 65 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L65

Unsafe call of an `error` type typed value.
const result = validateSplitInputs("10", "0", "61", "0");
expect(result.isValid).toBe(false);
expect(result.errors.time).toBeDefined();
});
});

describe("calculateSplits", () => {

Check warning on line 72 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L72

Unsafe call of an `error` type typed value.
it("generates correct number of splits for a 10K in km", () => {

Check warning on line 73 in vite-project/src/features/split-calculator/__tests__/utils.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/split-calculator/__tests__/utils.test.ts#L73

Unsafe call of an `error` type typed value.
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");
});
});

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);
});
});
Loading