diff --git a/app/e2e/course-404.spec.ts b/app/e2e/course-404.spec.ts
new file mode 100644
index 0000000..d075fe8
--- /dev/null
+++ b/app/e2e/course-404.spec.ts
@@ -0,0 +1,30 @@
+import { test, expect } from "@playwright/test";
+
+// Regression guard for the course-page soft-404. A loading.tsx at
+// app/src/app/courses/ created an implicit Suspense boundary that wrapped the
+// [course] child segment, so Next streamed the shell with a 200 status before
+// the page's notFound() ran — a nonexistent course (or an out-of-range
+// ?year=) returned HTTP 200 with the not-found UI (a soft 404 — bad for SEO).
+// The fix moves the /courses overview (page.tsx, loading.tsx, and its chart
+// components) into a (overview) route group, so the loading boundary no
+// longer wraps [course] and Next can resolve notFound() before flushing the
+// status. These assertions fail (miss returns 200) if it comes back.
+
+test("nonexistent course returns a real 404 status", async ({ page }) => {
+ const res = await page.goto("/courses/no-such-course-xyz");
+ expect(res?.status()).toBe(404);
+ await expect(page.getByText("Page not found")).toBeVisible();
+});
+
+test("out-of-range year on a real course returns a real 404 status", async ({ page }) => {
+ const res = await page.goto("/courses/im703-swansea?year=1999");
+ expect(res?.status()).toBe(404);
+ await expect(page.getByText("Page not found")).toBeVisible();
+});
+
+test("existing course returns 200 and renders the course page", async ({ page }) => {
+ // im703-swansea is a real course (editions 2022-2026) in the committed data.
+ const res = await page.goto("/courses/im703-swansea");
+ expect(res?.status()).toBe(200);
+ await expect(page.getByText("All years")).toBeVisible();
+});
diff --git a/app/src/app/courses/course-charts.tsx b/app/src/app/courses/(overview)/course-charts.tsx
similarity index 98%
rename from app/src/app/courses/course-charts.tsx
rename to app/src/app/courses/(overview)/course-charts.tsx
index 5bd695f..7a6a374 100644
--- a/app/src/app/courses/course-charts.tsx
+++ b/app/src/app/courses/(overview)/course-charts.tsx
@@ -85,7 +85,6 @@ export default function CourseCharts({
disciplineKey={disc.key}
color={disc.color}
label={disc.label}
- distance={distance}
/>
))}
diff --git a/app/src/app/courses/course-list.tsx b/app/src/app/courses/(overview)/course-list.tsx
similarity index 94%
rename from app/src/app/courses/course-list.tsx
rename to app/src/app/courses/(overview)/course-list.tsx
index 52d98e8..fcf26e8 100644
--- a/app/src/app/courses/course-list.tsx
+++ b/app/src/app/courses/(overview)/course-list.tsx
@@ -2,7 +2,7 @@
import Link from "next/link";
import type { CourseStats } from "@/lib/types";
-import { courseHref } from "@/lib/races-url";
+import { courseDetailHref } from "@/lib/races-url";
function formatTime(seconds: number): string {
if (seconds <= 0) return "—";
@@ -35,7 +35,7 @@ export default function CourseList({
{sorted.map((course) => (
diff --git a/app/src/app/courses/loading.tsx b/app/src/app/courses/(overview)/loading.tsx
similarity index 100%
rename from app/src/app/courses/loading.tsx
rename to app/src/app/courses/(overview)/loading.tsx
diff --git a/app/src/app/courses/page.tsx b/app/src/app/courses/(overview)/page.tsx
similarity index 100%
rename from app/src/app/courses/page.tsx
rename to app/src/app/courses/(overview)/page.tsx
diff --git a/app/src/app/courses/[course]/page.tsx b/app/src/app/courses/[course]/page.tsx
new file mode 100644
index 0000000..34a5acf
--- /dev/null
+++ b/app/src/app/courses/[course]/page.tsx
@@ -0,0 +1,318 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { getCourseInfo } from "@/lib/races";
+import {
+ getRaceStats,
+ getRaceSegmentData,
+ getCourseResults,
+ computeRaceStats,
+ buildTopFinishers,
+ getCourseYearSummary,
+ buildRaceSegmentData,
+} from "@/lib/data";
+import type { LeaderboardEntry, CourseYearSummaryRow, RaceStats, RaceSegmentData } from "@/lib/types";
+import { formatAthleteName, formatTime } from "@/lib/format";
+import { getCountryFlagISO } from "@/lib/flags";
+import ResultCard from "@/components/ResultCard";
+import RaceDistributions from "@/components/RaceDistributions";
+import { DISCIPLINE_COLORS } from "@/lib/colors";
+
+// Generate on demand — 178 courses, but keep parity with /race/[slug].
+export async function generateStaticParams() {
+ return [];
+}
+
+export const revalidate = false;
+
+interface PageProps {
+ params: Promise<{ course: string }>;
+ searchParams: Promise<{ year?: string }>;
+}
+
+function genderColor(gender: string): string {
+ if (gender === "Male") return "#3b82f6";
+ if (gender === "Female") return "#ec4899";
+ return "#6b7280";
+}
+
+function genderLabel(gender: string): string {
+ return gender || "Unlisted";
+}
+
+export async function generateMetadata({ params, searchParams }: PageProps): Promise {
+ const { course } = await params;
+ const { year } = await searchParams;
+ const info = getCourseInfo(course);
+ if (!info) return {};
+
+ const years = info.editions.map((e) => e.year);
+ const span = years.length > 1 ? `${years[years.length - 1]}–${years[0]}` : years[0];
+ const selected =
+ typeof year === "string" && year && info.editions.some((e) => e.year === year)
+ ? year
+ : undefined;
+ const canonical = selected ? `/courses/${course}?year=${selected}` : `/courses/${course}`;
+
+ if (selected) {
+ const title = `${info.name} ${selected} — Results & Time Distributions`;
+ return { title, alternates: { canonical } };
+ }
+ const title = `${info.name} — All Editions`;
+ const description = `Combined results across ${info.editions.length} editions (${span}) of ${info.name}: time distributions, discipline splits, and fastest finishers.`;
+ return { title, description, alternates: { canonical } };
+}
+
+export default async function CoursePage({ params, searchParams }: PageProps) {
+ const { course } = await params;
+ const { year } = await searchParams;
+ const info = getCourseInfo(course);
+ if (!info) notFound();
+
+ const selectedYear = typeof year === "string" && year ? year : undefined;
+ const selectedEdition = selectedYear
+ ? info.editions.find((e) => e.year === selectedYear)
+ : undefined;
+ if (selectedYear && !selectedEdition) notFound();
+
+ let stats: RaceStats;
+ let segmentData: RaceSegmentData;
+ let yearSummary: CourseYearSummaryRow[] | null = null;
+ const combined = !selectedEdition;
+
+ if (selectedEdition) {
+ stats = getRaceStats(selectedEdition.slug);
+ segmentData = getRaceSegmentData(selectedEdition.slug);
+ } else {
+ const pool = getCourseResults(info.editions.map((e) => e.slug));
+ stats = computeRaceStats(pool);
+ stats.maleLeaderboard = buildTopFinishers(pool, "Male");
+ stats.femaleLeaderboard = buildTopFinishers(pool, "Female");
+ segmentData = buildRaceSegmentData(pool);
+ yearSummary = getCourseYearSummary(pool);
+ }
+
+ const finishStats = stats.disciplines.find((d) => d.discipline === "Total");
+ const years = info.editions.map((e) => e.year);
+ const span = years.length > 1 ? `${years[years.length - 1]}–${years[0]}` : years[0];
+ const singleSlug = selectedEdition?.slug;
+
+ function resultHref(entry: LeaderboardEntry): string {
+ const slug = entry.raceSlug ?? singleSlug ?? "";
+ return `/race/${slug}/result/${entry.id}`;
+ }
+
+ const tagBase =
+ "px-3 py-1.5 rounded-full text-sm font-medium transition-colors";
+ const tagActive = "bg-white/10 text-white ring-1 ring-white/20";
+ const tagIdle = "text-gray-400 hover:text-white hover:bg-white/5";
+
+ return (
+
+
+
+ {/* Year tags */}
+
+
+ All years
+
+ {info.editions.map((e) => (
+
+ {e.year}
+
+ ))}
+
+
+ {/* Summary cards */}
+
+
+
+
+
+
+
+ {/* Discipline breakdown */}
+
+ Discipline Breakdown
+
+
+
+
+ Discipline
+ Fastest
+ Median
+ Average
+ Slowest
+
+
+
+ {stats.disciplines.map((d) => (
+
+
+ {d.discipline}
+
+ {formatTime(d.fastest)}
+ {formatTime(d.median)}
+ {formatTime(d.average)}
+ {formatTime(d.slowest)}
+
+ ))}
+
+
+
+
+
+ {/* Time distributions */}
+
+
+ {/* Demographics */}
+
+ Demographics
+
+
+
Gender Split
+
+ {stats.genderBreakdown.map((g) => (
+
+ ))}
+
+
+ {stats.genderBreakdown.map((g) => (
+
+
+
+
{genderLabel(g.gender)}
+
+
+ {g.count.toLocaleString()} ({g.percentage}%)
+ {formatTime(g.medianFinish)}
+
+
+ ))}
+
+
+
+
+
Age Groups
+
+ {stats.ageGroupBreakdown.map((ag) => {
+ const maxCount = stats.ageGroupBreakdown[0]?.count || 1;
+ const barWidth = Math.max(4, (ag.count / maxCount) * 100);
+ return (
+
+
{ag.ageGroup}
+
+
{ag.count}
+
+ );
+ })}
+
+
+
+
+
+ {/* Top finishers */}
+
+
+ {combined ? "Fastest Finishers (All Editions)" : "Top Finishers"}
+
+
+ {[
+ { title: "Top 10 Males", entries: stats.maleLeaderboard },
+ { title: "Top 10 Females", entries: stats.femaleLeaderboard },
+ ].map(({ title, entries }) => (
+
+
{title}
+
+
+
+ #
+ Name
+ {combined && Year }
+ AG
+ Finish
+
+
+
+ {entries.map((entry) => {
+ const flag = getCountryFlagISO(entry.countryISO);
+ return (
+
+ {entry.rank}
+
+
+ {flag} {formatAthleteName(entry.fullName)}
+
+
+ {combined && {entry.year} }
+ {entry.ageGroup}
+ {entry.finishTime}
+
+ );
+ })}
+
+
+
+ ))}
+
+
+
+ {/* All editions */}
+ {combined && yearSummary && (
+
+ All Editions
+
+
+
+
+ Year
+ Finishers
+ Median Finish
+
+
+
+ {yearSummary.map((row) => (
+
+
+
+ {row.year}
+
+
+ {row.finishers.toLocaleString()}
+
+ {row.medianFinish > 0 ? formatTime(row.medianFinish) : "—"}
+
+
+ ))}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/app/src/app/race/[slug]/page.tsx b/app/src/app/race/[slug]/page.tsx
index 01d0c43..1c74fd7 100644
--- a/app/src/app/race/[slug]/page.tsx
+++ b/app/src/app/race/[slug]/page.tsx
@@ -8,6 +8,7 @@ import ResultCard from "@/components/ResultCard";
import RaceDistributions from "@/components/RaceDistributions";
import { DISCIPLINE_COLORS } from "@/lib/colors";
import { getRaceLocation } from "@/lib/raceLocation";
+import { getCourseInfo, courseSlugOf } from "@/lib/races";
// Generate on demand — too many races to pre-render at build time.
export async function generateStaticParams() {
@@ -60,6 +61,9 @@ export default async function RacePage({ params }: PageProps) {
const finishStats = stats.disciplines.find((d) => d.discipline === "Total");
const location = getRaceLocation(race);
+ const courseSlug = courseSlugOf(slug);
+ const course = getCourseInfo(courseSlug);
+ const editionCount = course?.editions.length ?? 0;
const jsonLd = {
"@context": "https://schema.org",
@@ -85,6 +89,14 @@ export default async function RacePage({ params }: PageProps) {
{race.date}
{location && <> · {location}>}
+ {editionCount > 1 && (
+
+ View all {editionCount} editions →
+
+ )}
{/* Summary cards */}
diff --git a/app/src/components/CourseBarChart.tsx b/app/src/components/CourseBarChart.tsx
index 12edb36..f7f4f7d 100644
--- a/app/src/components/CourseBarChart.tsx
+++ b/app/src/components/CourseBarChart.tsx
@@ -3,7 +3,7 @@
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import type { CourseStats } from "@/lib/types";
-import { courseHref } from "@/lib/races-url";
+import { courseDetailHref } from "@/lib/races-url";
import { truncateLabel } from "./chart-utils";
function formatTime(seconds: number): string {
@@ -26,7 +26,6 @@ interface Props {
disciplineKey: DisciplineKey;
color: string;
label: string;
- distance: "70.3" | "140.6";
}
const SVG_WIDTH = 500;
@@ -46,7 +45,6 @@ export default function CourseBarChart({
disciplineKey,
color,
label,
- distance,
}: Props) {
const router = useRouter();
const [tooltipIdx, setTooltipIdx] = useState(null);
@@ -61,6 +59,7 @@ export default function CourseBarChart({
const data = sorted.map((c) => ({
name: c.displayName,
+ course: c.course,
seconds: c[disciplineKey],
finishers: c.totalFinishers,
editions: c.editions,
@@ -127,7 +126,7 @@ export default function CourseBarChart({
router.push(courseHref(distance, d.name))}
+ onClick={() => router.push(courseDetailHref(d.course))}
>
{/* Full-row hit area so the label and empty track are clickable too. */}
): CourseResult {
+ return {
+ id: 0, firstName: "", lastName: "", fullName: "", bib: "",
+ ageGroup: "M35-39", gender: "Male", city: "", state: "",
+ country: "", countryISO: "",
+ swimTime: "", bikeTime: "", runTime: "", t1Time: "", t2Time: "", finishTime: "9:00:00",
+ swimSeconds: 1000, bikeSeconds: 2000, runSeconds: 1500,
+ t1Seconds: 0, t2Seconds: 0, finishSeconds: 4500,
+ overallRank: 0, genderRank: 0, ageGroupRank: 0, status: "Finisher",
+ raceSlug: "im-x-2024", year: "2024",
+ ...over,
+ } as CourseResult;
+}
+
+describe("buildTopFinishers", () => {
+ const pool = [
+ cr({ gender: "Male", finishSeconds: 5000, fullName: "Slow", raceSlug: "im-x-2024", year: "2024" }),
+ cr({ gender: "Male", finishSeconds: 3000, fullName: "Fast", raceSlug: "im-x-2026", year: "2026" }),
+ cr({ gender: "Female", finishSeconds: 3500, fullName: "Fem", raceSlug: "im-x-2025", year: "2025" }),
+ ];
+
+ it("ranks a gender by fastest finish across editions with computed ranks", () => {
+ const top = buildTopFinishers(pool, "Male");
+ expect(top.map((e) => e.fullName)).toEqual(["Fast", "Slow"]);
+ expect(top.map((e) => e.rank)).toEqual([1, 2]);
+ });
+
+ it("carries the edition slug and year on each entry", () => {
+ const top = buildTopFinishers(pool, "Male");
+ expect(top[0].raceSlug).toBe("im-x-2026");
+ expect(top[0].year).toBe("2026");
+ });
+
+ it("filters to the requested gender", () => {
+ expect(buildTopFinishers(pool, "Female").map((e) => e.fullName)).toEqual(["Fem"]);
+ });
+
+ it("respects the limit", () => {
+ expect(buildTopFinishers(pool, "Male", 1).map((e) => e.fullName)).toEqual(["Fast"]);
+ });
+});
diff --git a/app/src/lib/__tests__/course-summary.test.ts b/app/src/lib/__tests__/course-summary.test.ts
new file mode 100644
index 0000000..c0f0290
--- /dev/null
+++ b/app/src/lib/__tests__/course-summary.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect } from "vitest";
+import { getCourseYearSummary } from "../data";
+import type { CourseResult } from "../types";
+
+function cr(over: Partial): CourseResult {
+ return {
+ id: 0, firstName: "", lastName: "", fullName: "", bib: "",
+ ageGroup: "M35-39", gender: "Male", city: "", state: "",
+ country: "", countryISO: "",
+ swimTime: "", bikeTime: "", runTime: "", t1Time: "", t2Time: "", finishTime: "",
+ swimSeconds: 0, bikeSeconds: 0, runSeconds: 0,
+ t1Seconds: 0, t2Seconds: 0, finishSeconds: 4500,
+ overallRank: 0, genderRank: 0, ageGroupRank: 0, status: "Finisher",
+ raceSlug: "im-x-2024", year: "2024",
+ ...over,
+ } as CourseResult;
+}
+
+describe("getCourseYearSummary", () => {
+ const pool = [
+ cr({ raceSlug: "im-x-2024", year: "2024", finishSeconds: 4000 }),
+ cr({ raceSlug: "im-x-2024", year: "2024", finishSeconds: 6000 }),
+ cr({ raceSlug: "im-x-2024", year: "2024", finishSeconds: 0 }),
+ cr({ raceSlug: "im-x-2026", year: "2026", finishSeconds: 5000 }),
+ ];
+
+ it("summarizes finishers and median finish per edition, newest first", () => {
+ const rows = getCourseYearSummary(pool);
+ expect(rows.map((r) => r.year)).toEqual(["2026", "2024"]);
+
+ const y2024 = rows.find((r) => r.year === "2024")!;
+ expect(y2024.finishers).toBe(3); // counts all rows, including the 0-second one
+ expect(y2024.medianFinish).toBe(5000); // median of [4000, 6000]; 0 excluded by >0 filter
+ expect(y2024.slug).toBe("im-x-2024");
+
+ const y2026 = rows.find((r) => r.year === "2026")!;
+ expect(y2026.finishers).toBe(1);
+ expect(y2026.medianFinish).toBe(5000);
+ expect(y2026.slug).toBe("im-x-2026");
+ });
+});
diff --git a/app/src/lib/__tests__/courses.test.ts b/app/src/lib/__tests__/courses.test.ts
new file mode 100644
index 0000000..412262b
--- /dev/null
+++ b/app/src/lib/__tests__/courses.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect } from "vitest";
+// getCourseInfo reads the manifest through getRaces(); __setRacesForTest
+// injects a synthetic manifest so the test never touches data/races.json.
+import { courseSlugOf, getCourseInfo, __setRacesForTest } from "../races";
+
+describe("courseSlugOf", () => {
+ it("strips a trailing -YYYY", () => {
+ expect(courseSlugOf("im703-swansea-2026")).toBe("im703-swansea");
+ expect(courseSlugOf("im-vitoria-2019")).toBe("im-vitoria");
+ });
+});
+
+describe("getCourseInfo", () => {
+ const manifest = [
+ { slug: "im703-swansea-2024", name: "2024 IRONMAN 70.3 Swansea", date: "2024-07-14", location: "Swansea, Wales, UK", finishers: 1500 },
+ { slug: "im703-swansea-2026", name: "2026 IRONMAN 70.3 Swansea", date: "2026-07-12", location: "Swansea, Wales, UK", finishers: 1744 },
+ { slug: "im-frankfurt-2005", name: "2005 IRONMAN Frankfurt", date: "2005-07-03", location: "", finishers: 2000 },
+ ];
+
+ it("groups editions of one course newest-first with a year-stripped name", () => {
+ __setRacesForTest(manifest);
+ const info = getCourseInfo("im703-swansea");
+ expect(info).toBeDefined();
+ expect(info!.name).toBe("IRONMAN 70.3 Swansea");
+ expect(info!.location).toBe("Swansea, Wales, UK");
+ expect(info!.distance).toBe("70.3");
+ expect(info!.editions.map((e) => e.year)).toEqual(["2026", "2024"]);
+ expect(info!.editions[0].slug).toBe("im703-swansea-2026");
+ expect(info!.editions[0].finishers).toBe(1744);
+ });
+
+ it("classifies non-703 slugs as 140.6", () => {
+ __setRacesForTest(manifest);
+ expect(getCourseInfo("im-frankfurt")!.distance).toBe("140.6");
+ });
+
+ it("returns undefined for an unknown course", () => {
+ __setRacesForTest(manifest);
+ expect(getCourseInfo("does-not-exist")).toBeUndefined();
+ });
+});
diff --git a/app/src/lib/__tests__/race-stats.test.ts b/app/src/lib/__tests__/race-stats.test.ts
new file mode 100644
index 0000000..17b7bce
--- /dev/null
+++ b/app/src/lib/__tests__/race-stats.test.ts
@@ -0,0 +1,46 @@
+import { describe, it, expect } from "vitest";
+import { computeRaceStats } from "../data";
+import type { AthleteResult } from "../types";
+
+function finisher(over: Partial): AthleteResult {
+ return {
+ id: 0, firstName: "", lastName: "", fullName: "", bib: "",
+ ageGroup: "M35-39", gender: "Male", city: "", state: "",
+ country: "", countryISO: "",
+ swimTime: "", bikeTime: "", runTime: "", t1Time: "", t2Time: "", finishTime: "",
+ swimSeconds: 1000, bikeSeconds: 2000, runSeconds: 1500,
+ t1Seconds: 0, t2Seconds: 0, finishSeconds: 4500,
+ overallRank: 0, genderRank: 0, ageGroupRank: 0, status: "Finisher",
+ ...over,
+ } as AthleteResult;
+}
+
+describe("computeRaceStats", () => {
+ const results = [
+ finisher({ gender: "Male", genderRank: 1, finishSeconds: 4000, fullName: "A" }),
+ finisher({ gender: "Male", genderRank: 2, finishSeconds: 5000, fullName: "B" }),
+ finisher({ gender: "Female", genderRank: 1, finishSeconds: 4200, fullName: "C" }),
+ ];
+
+ it("counts finishers", () => {
+ expect(computeRaceStats(results).totalFinishers).toBe(3);
+ });
+
+ it("computes the Total discipline fastest/slowest", () => {
+ const total = computeRaceStats(results).disciplines.find((d) => d.discipline === "Total")!;
+ expect(total.fastest).toBe(4000);
+ expect(total.slowest).toBe(5000);
+ });
+
+ it("builds gender leaderboards ordered by genderRank", () => {
+ const stats = computeRaceStats(results);
+ expect(stats.maleLeaderboard.map((e) => e.fullName)).toEqual(["A", "B"]);
+ expect(stats.femaleLeaderboard.map((e) => e.fullName)).toEqual(["C"]);
+ });
+
+ it("produces the four discipline histograms", () => {
+ const h = computeRaceStats(results).histograms;
+ expect(h.swim.totalAthletes).toBeGreaterThan(0);
+ expect(h.finish.totalAthletes).toBeGreaterThan(0);
+ });
+});
diff --git a/app/src/lib/__tests__/races-url.test.ts b/app/src/lib/__tests__/races-url.test.ts
index 0444e66..007e1ad 100644
--- a/app/src/lib/__tests__/races-url.test.ts
+++ b/app/src/lib/__tests__/races-url.test.ts
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import {
filtersToQueryString,
parseFiltersFromParams,
- courseHref,
+ courseDetailHref,
} from "../races-url";
describe("filtersToQueryString", () => {
@@ -75,12 +75,8 @@ describe("parseFiltersFromParams", () => {
});
});
-describe("courseHref", () => {
- it("builds a /races link with distance and the course name as the query", () => {
- expect(courseHref("140.6", "Wisconsin")).toBe("/races?distance=140.6&q=Wisconsin");
- });
-
- it("URL-encodes the course name", () => {
- expect(courseHref("70.3", "St. George")).toBe("/races?distance=70.3&q=St.+George");
+describe("courseDetailHref", () => {
+ it("links to the course detail page by base slug", () => {
+ expect(courseDetailHref("im-wisconsin")).toBe("/courses/im-wisconsin");
});
});
diff --git a/app/src/lib/data.ts b/app/src/lib/data.ts
index 1707af8..cf47627 100644
--- a/app/src/lib/data.ts
+++ b/app/src/lib/data.ts
@@ -1,7 +1,7 @@
import fs from "fs";
import path from "path";
import { gunzipSync } from "zlib";
-import { AggregateStats, AthleteResult, AthleteSearchEntry, AgeGroupBreakdown, CourseStats, DisciplineStats, DistanceStats, GenderBreakdown, HistogramBin, HistogramData, LeaderboardEntry, RaceHistogramData, RaceSegmentData, RaceStats } from "./types";
+import { AggregateStats, AthleteResult, AthleteSearchEntry, AgeGroupBreakdown, CourseResult, CourseStats, CourseYearSummaryRow, DisciplineStats, DistanceStats, GenderBreakdown, HistogramBin, HistogramData, LeaderboardEntry, RaceHistogramData, RaceSegmentData, RaceStats } from "./types";
import { getRaces } from "./races";
import {
BIN_SIZES,
@@ -470,9 +470,10 @@ export function getDisciplineHistogram(
return computeHistogram(allSeconds, athleteSeconds, BIN_SIZES[discipline]);
}
-export function getRaceStats(raceSlug: string): RaceStats {
- const results = getAllResults(raceSlug);
-
+// Pure race statistics over an already-loaded result set. Split from
+// getRaceStats so the course detail page can compute over a pooled,
+// multi-edition result set, and so it is unit-testable without the corpus.
+export function computeRaceStats(results: AthleteResult[]): RaceStats {
const disciplineKeys: { key: Discipline; label: string }[] = [
{ key: "swim", label: "Swim" },
{ key: "bike", label: "Bike" },
@@ -495,7 +496,6 @@ export function getRaceStats(raceSlug: string): RaceStats {
};
});
- // Gender breakdown
const genderMap = new Map();
for (const r of results) {
const list = genderMap.get(r.gender) || [];
@@ -516,7 +516,6 @@ export function getRaceStats(raceSlug: string): RaceStats {
})
.sort((a, b) => b.count - a.count);
- // Age group breakdown
const ageGroupMap = new Map();
for (const r of results) {
const list = ageGroupMap.get(r.ageGroup) || [];
@@ -537,7 +536,6 @@ export function getRaceStats(raceSlug: string): RaceStats {
})
.sort((a, b) => b.count - a.count);
- // Top 10 leaderboards by gender
function buildLeaderboard(gender: string): LeaderboardEntry[] {
return results
.filter((r) => r.gender === gender)
@@ -557,56 +555,56 @@ export function getRaceStats(raceSlug: string): RaceStats {
runTime: r.runTime,
}));
}
- const maleLeaderboard = buildLeaderboard("Male");
- const femaleLeaderboard = buildLeaderboard("Female");
-
- // Histograms — use precomputed data if available
- const precomputed = loadPrecomputedHistograms(raceSlug);
- const histograms = (() => {
- if (precomputed) {
- const toRaceHistogram = (key: Discipline): RaceHistogramData => {
- const src = precomputed[key]?.overall;
- if (!src || src.bins.length === 0) {
- return computeRaceHistogram(results.map((r) => getSeconds(r, key)), BIN_SIZES[key]);
- }
- return {
- bins: src.bins.map((b: PrecomputedBin) => ({
- label: b.label,
- rangeStart: b.rangeStart,
- rangeEnd: b.rangeEnd,
- count: b.count,
- isAthlete: false,
- })),
- medianSeconds: src.medianSeconds,
- totalAthletes: src.totalAthletes,
- };
- };
- return {
- swim: toRaceHistogram("swim"),
- bike: toRaceHistogram("bike"),
- run: toRaceHistogram("run"),
- finish: toRaceHistogram("finish"),
- };
- }
- return {
- swim: computeRaceHistogram(results.map((r) => r.swimSeconds), BIN_SIZES.swim),
- bike: computeRaceHistogram(results.map((r) => r.bikeSeconds), BIN_SIZES.bike),
- run: computeRaceHistogram(results.map((r) => r.runSeconds), BIN_SIZES.run),
- finish: computeRaceHistogram(results.map((r) => r.finishSeconds), BIN_SIZES.finish),
- };
- })();
return {
totalFinishers: results.length,
disciplines,
genderBreakdown,
ageGroupBreakdown,
- maleLeaderboard,
- femaleLeaderboard,
- histograms,
+ maleLeaderboard: buildLeaderboard("Male"),
+ femaleLeaderboard: buildLeaderboard("Female"),
+ histograms: {
+ swim: computeRaceHistogram(results.map((r) => r.swimSeconds), BIN_SIZES.swim),
+ bike: computeRaceHistogram(results.map((r) => r.bikeSeconds), BIN_SIZES.bike),
+ run: computeRaceHistogram(results.map((r) => r.runSeconds), BIN_SIZES.run),
+ finish: computeRaceHistogram(results.map((r) => r.finishSeconds), BIN_SIZES.finish),
+ },
};
}
+export function getRaceStats(raceSlug: string): RaceStats {
+ const results = getAllResults(raceSlug);
+ const stats = computeRaceStats(results);
+
+ // Overlay precomputed per-slug histograms when present (race-page perf path).
+ const precomputed = loadPrecomputedHistograms(raceSlug);
+ if (precomputed) {
+ const overlay = (key: keyof RaceStats["histograms"]): RaceHistogramData => {
+ const src = precomputed[key]?.overall;
+ if (!src || src.bins.length === 0) return stats.histograms[key];
+ return {
+ bins: src.bins.map((b: PrecomputedBin) => ({
+ label: b.label,
+ rangeStart: b.rangeStart,
+ rangeEnd: b.rangeEnd,
+ count: b.count,
+ isAthlete: false,
+ })),
+ medianSeconds: src.medianSeconds,
+ totalAthletes: src.totalAthletes,
+ };
+ };
+ stats.histograms = {
+ swim: overlay("swim"),
+ bike: overlay("bike"),
+ run: overlay("run"),
+ finish: overlay("finish"),
+ };
+ }
+
+ return stats;
+}
+
// Numeric-first ordering: bands with a leading age (e.g. "18-24") sort by that
// age ascending; non-numeric bands (e.g. "PRO") come after, alphabetically.
function compareAgeBands(a: string, b: string): number {
@@ -665,3 +663,63 @@ export function buildRaceSegmentData(results: AthleteResult[]): RaceSegmentData
return { swim, bike, run, finish, genderIdx, ageBandIdx, genders, ageBands };
}
+
+// Pool finisher rows across a course's editions, tagging each with its source
+// edition slug + year so combined leaderboards can link to the right result.
+export function getCourseResults(slugs: string[]): CourseResult[] {
+ const pool: CourseResult[] = [];
+ for (const slug of slugs) {
+ const year = slug.match(/-(\d{4})$/)?.[1] ?? "";
+ for (const r of getAllResults(slug)) {
+ pool.push({ ...r, raceSlug: slug, year });
+ }
+ }
+ return pool;
+}
+
+// Combined top-N leaderboard by fastest finish time across editions. Unlike
+// the per-race leaderboard (ordered by genderRank), rank is recomputed 1..N
+// because genderRank repeats across editions.
+export function buildTopFinishers(
+ results: CourseResult[],
+ gender: string,
+ limit = 10,
+): LeaderboardEntry[] {
+ return results
+ .filter((r) => r.gender === gender && r.finishSeconds > 0)
+ .sort((a, b) => a.finishSeconds - b.finishSeconds)
+ .slice(0, limit)
+ .map((r, i) => ({
+ id: r.id,
+ rank: i + 1,
+ fullName: r.fullName,
+ country: r.country,
+ countryISO: r.countryISO,
+ ageGroup: r.ageGroup,
+ gender: r.gender,
+ finishTime: r.finishTime,
+ swimTime: r.swimTime,
+ bikeTime: r.bikeTime,
+ runTime: r.runTime,
+ raceSlug: r.raceSlug,
+ year: r.year,
+ }));
+}
+
+// Per-edition roll-up for the course page's "All editions" table.
+export function getCourseYearSummary(results: CourseResult[]): CourseYearSummaryRow[] {
+ const byEdition = new Map();
+ for (const r of results) {
+ const list = byEdition.get(r.raceSlug) || [];
+ list.push(r);
+ byEdition.set(r.raceSlug, list);
+ }
+ return Array.from(byEdition.entries())
+ .map(([slug, group]) => ({
+ slug,
+ year: group[0].year,
+ finishers: group.length,
+ medianFinish: computeMedian(group.map((r) => r.finishSeconds).filter((s) => s > 0)),
+ }))
+ .sort((a, b) => b.year.localeCompare(a.year));
+}
diff --git a/app/src/lib/races-url.ts b/app/src/lib/races-url.ts
index 7fe07cf..f33d09c 100644
--- a/app/src/lib/races-url.ts
+++ b/app/src/lib/races-url.ts
@@ -43,10 +43,9 @@ export function parseFiltersFromParams(params: URLSearchParams): RaceUrlFilters
}
/**
- * Build a /races link pre-filtered to a course: the course's distance plus its
- * display name as the free-text query. Reuses filtersToQueryString so course
- * links stay consistent with the Races URL format.
+ * Link to a course's detail page (all editions aggregated), keyed by the
+ * course's base slug (e.g. "im-wisconsin").
*/
-export function courseHref(distance: string, displayName: string): string {
- return `/races?${filtersToQueryString({ distance, year: "All", query: displayName })}`;
+export function courseDetailHref(courseSlug: string): string {
+ return `/courses/${courseSlug}`;
}
diff --git a/app/src/lib/races.ts b/app/src/lib/races.ts
index c66a3f0..0791d88 100644
--- a/app/src/lib/races.ts
+++ b/app/src/lib/races.ts
@@ -1,6 +1,7 @@
import fs from "fs";
import path from "path";
-import { RaceInfo } from "./types";
+import { getRaceLocation } from "./raceLocation";
+import { RaceInfo, CourseInfo, CourseEdition } from "./types";
// Race-manifest access (data/races.json only). This module is imported by
// instrumentation.ts — which is bundled into EVERY Node function — and by
@@ -36,6 +37,18 @@ function loadRaces(): RaceInfo[] {
let racesCache: RaceInfo[] | null = null;
+// Test-only: inject a synthetic manifest so pure grouping logic can be tested
+// without depending on the committed data/races.json.
+export function __setRacesForTest(races: RaceInfo[]): void {
+ racesCache = races.map((e) => ({
+ slug: e.slug,
+ name: e.name,
+ date: e.date,
+ location: e.location,
+ finishers: e.finishers || 0,
+ }));
+}
+
export function getRaces(): RaceInfo[] {
if (!racesCache) racesCache = loadRaces();
return racesCache;
@@ -53,3 +66,35 @@ export function getGlobalStats(): { raceCount: number; totalResults: number } {
totalResults: entries.reduce((sum, e) => sum + (e.finishers || 0), 0),
};
}
+
+export function courseSlugOf(slug: string): string {
+ return slug.replace(/-\d{4}$/, "");
+}
+
+function editionYear(slug: string): string {
+ const m = slug.match(/-(\d{4})$/);
+ return m ? m[1] : "";
+}
+
+export function getCourseInfo(courseSlug: string): CourseInfo | undefined {
+ const editions: CourseEdition[] = getRaces()
+ .filter((r) => courseSlugOf(r.slug) === courseSlug)
+ .map((r) => ({
+ slug: r.slug,
+ year: editionYear(r.slug),
+ date: r.date,
+ finishers: r.finishers,
+ }))
+ .sort((a, b) => b.year.localeCompare(a.year)); // newest first
+
+ if (editions.length === 0) return undefined;
+
+ const recent = getRaces().find((r) => r.slug === editions[0].slug)!;
+ return {
+ course: courseSlug,
+ name: recent.name.replace(/^\d{4}\s+/, ""),
+ location: getRaceLocation(recent) ?? "",
+ distance: courseSlug.startsWith("im703-") ? "70.3" : "140.6",
+ editions,
+ };
+}
diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts
index c350dda..67650f0 100644
--- a/app/src/lib/types.ts
+++ b/app/src/lib/types.ts
@@ -28,6 +28,18 @@ export interface AthleteResult {
status: string;
}
+export interface CourseResult extends AthleteResult {
+ raceSlug: string;
+ year: string;
+}
+
+export interface CourseYearSummaryRow {
+ slug: string;
+ year: string;
+ finishers: number;
+ medianFinish: number;
+}
+
export interface HistogramBin {
label: string;
rangeStart: number;
@@ -92,6 +104,21 @@ export interface RaceInfo {
finishers: number;
}
+export interface CourseEdition {
+ slug: string;
+ year: string;
+ date: string;
+ finishers: number;
+}
+
+export interface CourseInfo {
+ course: string; // base slug, e.g. "im703-swansea"
+ name: string; // display name with leading year stripped
+ location: string;
+ distance: "70.3" | "140.6";
+ editions: CourseEdition[]; // newest first
+}
+
export interface DisciplineStats {
discipline: string;
fastest: number;
@@ -128,6 +155,8 @@ export interface LeaderboardEntry {
swimTime: string;
bikeTime: string;
runTime: string;
+ raceSlug?: string; // set only on combined (multi-edition) leaderboards
+ year?: string; // set only on combined leaderboards
}
export interface RaceHistogramData {