From e5c10a7b66649de8c80f4ac615ee15d1272437bb Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Thu, 16 Jul 2026 12:36:59 -0700 Subject: [PATCH 1/9] feat: add course grouping helpers to races manifest layer --- app/src/lib/__tests__/courses.test.ts | 41 +++++++++++++++++++++++ app/src/lib/races.ts | 47 ++++++++++++++++++++++++++- app/src/lib/types.ts | 15 +++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 app/src/lib/__tests__/courses.test.ts 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/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..9d7ab66 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -92,6 +92,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; From f1182602c2c50c424d3c8fa937b859fc38e2df6f Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Thu, 16 Jul 2026 12:42:47 -0700 Subject: [PATCH 2/9] feat: point course links to the new course detail page --- app/src/app/courses/course-charts.tsx | 1 - app/src/app/courses/course-list.tsx | 4 ++-- app/src/components/CourseBarChart.tsx | 7 +++---- app/src/lib/__tests__/races-url.test.ts | 12 ++++-------- app/src/lib/races-url.ts | 9 ++++----- 5 files changed, 13 insertions(+), 20 deletions(-) diff --git a/app/src/app/courses/course-charts.tsx b/app/src/app/courses/course-charts.tsx index 5bd695f..7a6a374 100644 --- a/app/src/app/courses/course-charts.tsx +++ b/app/src/app/courses/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/course-list.tsx index 52d98e8..fcf26e8 100644 --- a/app/src/app/courses/course-list.tsx +++ b/app/src/app/courses/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/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. */} { @@ -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/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}`; } From 2f45f032fdc80c454a46eccb1202d9e76bb3f71b Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Thu, 16 Jul 2026 12:47:49 -0700 Subject: [PATCH 3/9] refactor: extract pure computeRaceStats from getRaceStats --- app/src/lib/__tests__/race-stats.test.ts | 46 ++++++++++++ app/src/lib/data.ts | 92 ++++++++++++------------ 2 files changed, 91 insertions(+), 47 deletions(-) create mode 100644 app/src/lib/__tests__/race-stats.test.ts 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/data.ts b/app/src/lib/data.ts index 1707af8..e383cb9 100644 --- a/app/src/lib/data.ts +++ b/app/src/lib/data.ts @@ -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 { From b3dc41f515e2f85c639695b1bd90d4a6addc7125 Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Thu, 16 Jul 2026 12:54:21 -0700 Subject: [PATCH 4/9] feat: add course result pooling and combined leaderboards --- app/src/lib/__tests__/course-results.test.ts | 45 ++++++++++++++++++++ app/src/lib/data.ts | 44 ++++++++++++++++++- app/src/lib/types.ts | 7 +++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 app/src/lib/__tests__/course-results.test.ts diff --git a/app/src/lib/__tests__/course-results.test.ts b/app/src/lib/__tests__/course-results.test.ts new file mode 100644 index 0000000..c685a72 --- /dev/null +++ b/app/src/lib/__tests__/course-results.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { buildTopFinishers } 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: "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/data.ts b/app/src/lib/data.ts index e383cb9..5d94d14 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, DisciplineStats, DistanceStats, GenderBreakdown, HistogramBin, HistogramData, LeaderboardEntry, RaceHistogramData, RaceSegmentData, RaceStats } from "./types"; import { getRaces } from "./races"; import { BIN_SIZES, @@ -663,3 +663,45 @@ 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, + })); +} diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 9d7ab66..333b633 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -28,6 +28,11 @@ export interface AthleteResult { status: string; } +export interface CourseResult extends AthleteResult { + raceSlug: string; + year: string; +} + export interface HistogramBin { label: string; rangeStart: number; @@ -143,6 +148,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 { From 11113d1017a4bf70e1195391acaac52a22643388 Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Thu, 16 Jul 2026 12:58:21 -0700 Subject: [PATCH 5/9] feat: add per-edition course summary roll-up --- app/src/lib/__tests__/course-summary.test.ts | 34 ++++++++++++++++++++ app/src/lib/data.ts | 20 +++++++++++- app/src/lib/types.ts | 7 ++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 app/src/lib/__tests__/course-summary.test.ts 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..197bce2 --- /dev/null +++ b/app/src/lib/__tests__/course-summary.test.ts @@ -0,0 +1,34 @@ +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-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(2); + expect(y2024.medianFinish).toBe(5000); // median of [4000, 6000] + expect(y2024.slug).toBe("im-x-2024"); + }); +}); diff --git a/app/src/lib/data.ts b/app/src/lib/data.ts index 5d94d14..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, CourseResult, 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, @@ -705,3 +705,21 @@ export function buildTopFinishers( 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/types.ts b/app/src/lib/types.ts index 333b633..67650f0 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -33,6 +33,13 @@ export interface CourseResult extends AthleteResult { year: string; } +export interface CourseYearSummaryRow { + slug: string; + year: string; + finishers: number; + medianFinish: number; +} + export interface HistogramBin { label: string; rangeStart: number; From 0e42b25fa2bd047ec054584f77d79c95786b6481 Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Thu, 16 Jul 2026 13:01:43 -0700 Subject: [PATCH 6/9] test: cover finishSeconds filter and second edition in course summary --- app/src/lib/__tests__/course-summary.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/lib/__tests__/course-summary.test.ts b/app/src/lib/__tests__/course-summary.test.ts index 197bce2..c0f0290 100644 --- a/app/src/lib/__tests__/course-summary.test.ts +++ b/app/src/lib/__tests__/course-summary.test.ts @@ -20,15 +20,22 @@ 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(2); - expect(y2024.medianFinish).toBe(5000); // median of [4000, 6000] + 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"); }); }); From 4fce988cf3fa8f93d83c0fb425d18b8d752f6d18 Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Thu, 16 Jul 2026 13:06:31 -0700 Subject: [PATCH 7/9] feat: add course detail page aggregating all editions --- app/src/app/courses/[course]/page.tsx | 314 ++++++++++++++++++++++++++ app/src/app/race/[slug]/page.tsx | 12 + 2 files changed, 326 insertions(+) create mode 100644 app/src/app/courses/[course]/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..2743c9e --- /dev/null +++ b/app/src/app/courses/[course]/page.tsx @@ -0,0 +1,314 @@ +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 canonical = year ? `/courses/${course}?year=${year}` : `/courses/${course}`; + + if (year) { + const title = `${info.name} ${year} — 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 ( +
    +
    +

    + {info.name} + {selectedEdition ? ` ${selectedEdition.year}` : ""} +

    +

    + {info.location && <>{info.location} · } + {combined + ? `${info.editions.length} edition${info.editions.length !== 1 ? "s" : ""}${info.editions.length > 1 ? ` · ${span}` : ""}` + : selectedEdition!.date} +

    +
    + + {/* Year tags */} +
    + + All years + + {info.editions.map((e) => ( + + {e.year} + + ))} +
    + + {/* Summary cards */} +
    + + + + +
    + + {/* Discipline breakdown */} +
    +

    Discipline Breakdown

    +
    + + + + + + + + + + + + {stats.disciplines.map((d) => ( + + + + + + + + ))} + +
    DisciplineFastestMedianAverageSlowest
    + {d.discipline} + {formatTime(d.fastest)}{formatTime(d.median)}{formatTime(d.average)}{formatTime(d.slowest)}
    +
    +
    + + {/* Time distributions */} +
    +

    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}

    + + + + + + {combined && } + + + + + + {entries.map((entry) => { + const flag = getCountryFlagISO(entry.countryISO); + return ( + + + + {combined && } + + + + ); + })} + +
    #NameYearAGFinish
    {entry.rank} + + {flag} {formatAthleteName(entry.fullName)} + + {entry.year}{entry.ageGroup}{entry.finishTime}
    +
    + ))} +
    +
    + + {/* All editions */} + {combined && yearSummary && ( +
    +

    All Editions

    +
    + + + + + + + + + + {yearSummary.map((row) => ( + + + + + + ))} + +
    YearFinishersMedian Finish
    + + {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 */} From d002ca2e2981b367eee800ef78383a72622f5725 Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Thu, 16 Jul 2026 13:15:03 -0700 Subject: [PATCH 8/9] fix: don't emit results metadata for an invalid course ?year --- app/src/app/courses/[course]/page.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/app/courses/[course]/page.tsx b/app/src/app/courses/[course]/page.tsx index 2743c9e..34a5acf 100644 --- a/app/src/app/courses/[course]/page.tsx +++ b/app/src/app/courses/[course]/page.tsx @@ -48,10 +48,14 @@ export async function generateMetadata({ params, searchParams }: PageProps): Pro const years = info.editions.map((e) => e.year); const span = years.length > 1 ? `${years[years.length - 1]}–${years[0]}` : years[0]; - const canonical = year ? `/courses/${course}?year=${year}` : `/courses/${course}`; + 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 (year) { - const title = `${info.name} ${year} — Results & Time Distributions`; + if (selected) { + const title = `${info.name} ${selected} — Results & Time Distributions`; return { title, alternates: { canonical } }; } const title = `${info.name} — All Editions`; From f20e4cdc78d8b96b134b549ace78024d2859bbc3 Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Thu, 16 Jul 2026 13:26:00 -0700 Subject: [PATCH 9/9] fix: return a real 404 for nonexistent course pages src/app/courses/loading.tsx created a Suspense boundary wrapping the [course] child segment, so Next streamed the shell with HTTP 200 before notFound() resolved (soft 404 for /courses/ and out-of-range ?year=). Moves the overview-only files (page.tsx, loading.tsx, and its chart components) into a (overview) route group so the loading boundary no longer wraps [course]. Same class of bug as /athlete/[slug], fixed in PR #257. Adds e2e/course-404.spec.ts mirroring athlete-404.spec.ts. --- app/e2e/course-404.spec.ts | 30 +++++++++++++++++++ .../{ => (overview)}/course-charts.tsx | 0 .../courses/{ => (overview)}/course-list.tsx | 0 .../app/courses/{ => (overview)}/loading.tsx | 0 app/src/app/courses/{ => (overview)}/page.tsx | 0 5 files changed, 30 insertions(+) create mode 100644 app/e2e/course-404.spec.ts rename app/src/app/courses/{ => (overview)}/course-charts.tsx (100%) rename app/src/app/courses/{ => (overview)}/course-list.tsx (100%) rename app/src/app/courses/{ => (overview)}/loading.tsx (100%) rename app/src/app/courses/{ => (overview)}/page.tsx (100%) 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 100% rename from app/src/app/courses/course-charts.tsx rename to app/src/app/courses/(overview)/course-charts.tsx diff --git a/app/src/app/courses/course-list.tsx b/app/src/app/courses/(overview)/course-list.tsx similarity index 100% rename from app/src/app/courses/course-list.tsx rename to app/src/app/courses/(overview)/course-list.tsx 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