diff --git a/app/src/app/race/[slug]/page.tsx b/app/src/app/race/[slug]/page.tsx
index 226c7d3..01d0c43 100644
--- a/app/src/app/race/[slug]/page.tsx
+++ b/app/src/app/race/[slug]/page.tsx
@@ -1,18 +1,14 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Link from "next/link";
-import { getRaceBySlug, getRaceStats } from "@/lib/data";
+import { getRaceBySlug, getRaceStats, getRaceSegmentData } from "@/lib/data";
import { formatAthleteName, formatTime } from "@/lib/format";
import { getCountryFlagISO } from "@/lib/flags";
-import dynamic from "next/dynamic";
import ResultCard from "@/components/ResultCard";
+import RaceDistributions from "@/components/RaceDistributions";
import { DISCIPLINE_COLORS } from "@/lib/colors";
import { getRaceLocation } from "@/lib/raceLocation";
-const RaceHistogram = dynamic(() => import("@/components/RaceHistogram"), {
- loading: () =>
,
-});
-
// Generate on demand — too many races to pre-render at build time.
export async function generateStaticParams() {
return [];
@@ -59,6 +55,7 @@ export default async function RacePage({ params }: PageProps) {
if (!race) notFound();
const stats = getRaceStats(slug);
+ const segmentData = getRaceSegmentData(slug);
const finishStats = stats.disciplines.find((d) => d.discipline === "Total");
@@ -129,23 +126,10 @@ export default async function RacePage({ params }: PageProps) {
- {/* Time distributions */}
+ {/* Time distributions (filterable by gender + age group) */}
Time Distributions
-
- {(["swim", "bike", "run", "finish"] as const).map((key) => {
- const label = key === "finish" ? "Total" : key.charAt(0).toUpperCase() + key.slice(1);
- return (
-
-
-
- );
- })}
-
+
{/* Demographics */}
diff --git a/app/src/components/RaceDistributions.tsx b/app/src/components/RaceDistributions.tsx
new file mode 100644
index 0000000..28c1bdb
--- /dev/null
+++ b/app/src/components/RaceDistributions.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import RaceHistogram from "./RaceHistogram";
+import { BIN_SIZES, computeRaceHistogram, filterSegment } from "@/lib/histogram";
+import { DISCIPLINE_COLORS } from "@/lib/colors";
+import type { RaceSegmentData } from "@/lib/types";
+
+const DISCIPLINES = [
+ { key: "swim", label: "Swim", seconds: (d: RaceSegmentData) => d.swim },
+ { key: "bike", label: "Bike", seconds: (d: RaceSegmentData) => d.bike },
+ { key: "run", label: "Run", seconds: (d: RaceSegmentData) => d.run },
+ { key: "finish", label: "Total", seconds: (d: RaceSegmentData) => d.finish },
+] as const;
+
+const ANY = -1;
+
+export default function RaceDistributions({ data }: { data: RaceSegmentData }) {
+ const [genderIdx, setGenderIdx] = useState(ANY);
+ const [bandIdx, setBandIdx] = useState(ANY);
+
+ const indices = useMemo(
+ () => filterSegment(data, genderIdx, bandIdx),
+ [data, genderIdx, bandIdx]
+ );
+
+ const histograms = useMemo(
+ () =>
+ DISCIPLINES.map((d) => {
+ const all = d.seconds(data);
+ const seconds = indices.map((i) => all[i]);
+ return {
+ key: d.key,
+ label: d.label,
+ histogram: computeRaceHistogram(seconds, BIN_SIZES[d.key]),
+ };
+ }),
+ [data, indices]
+ );
+
+ const isFiltered = genderIdx !== ANY || bandIdx !== ANY;
+ const total = data.swim.length;
+
+ function reset() {
+ setGenderIdx(ANY);
+ setBandIdx(ANY);
+ }
+
+ const selectClass =
+ "bg-gray-800 border border-gray-700 text-white text-sm rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500";
+
+ return (
+
+ {/* Filter controls */}
+
+
+
+
+
+
+
+
+
+
+
+ {isFiltered && (
+
+
+ {indices.length.toLocaleString()} of {total.toLocaleString()} finishers
+
+
+
+ )}
+
+
+ {indices.length === 0 ? (
+
+ No finishers match this filter.
+
+ ) : (
+
+ {histograms.map((h) => (
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/src/lib/__tests__/histogram.test.ts b/app/src/lib/__tests__/histogram.test.ts
new file mode 100644
index 0000000..90b27d4
--- /dev/null
+++ b/app/src/lib/__tests__/histogram.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect } from "vitest";
+import {
+ deriveAgeBand,
+ filterSegment,
+ computeRaceHistogram,
+ computeMedian,
+} from "../histogram";
+
+describe("deriveAgeBand", () => {
+ it("strips a leading M", () => expect(deriveAgeBand("M35-39")).toBe("35-39"));
+ it("strips a leading F", () => expect(deriveAgeBand("F35-39")).toBe("35-39"));
+ it("strips M from PRO", () => expect(deriveAgeBand("MPRO")).toBe("PRO"));
+ it("strips F from PRO", () => expect(deriveAgeBand("FPRO")).toBe("PRO"));
+ it("passes through non-matching values", () =>
+ expect(deriveAgeBand("Hamad tawom3")).toBe("Hamad tawom3"));
+ it("passes through empty string", () => expect(deriveAgeBand("")).toBe(""));
+});
+
+describe("filterSegment", () => {
+ // gender: 0=Male, 1=Female; band: 0="18-24", 1="25-29"
+ const data = {
+ genderIdx: [0, 0, 1, 1],
+ ageBandIdx: [0, 1, 0, 1],
+ };
+ it("returns all indices when both are 'any' (-1)", () =>
+ expect(filterSegment(data, -1, -1)).toEqual([0, 1, 2, 3]));
+ it("filters by gender only", () =>
+ expect(filterSegment(data, 1, -1)).toEqual([2, 3]));
+ it("filters by age band only", () =>
+ expect(filterSegment(data, -1, 0)).toEqual([0, 2]));
+ it("filters by both", () =>
+ expect(filterSegment(data, 0, 1)).toEqual([1]));
+ it("returns empty for a combination with no members", () => {
+ const single = { genderIdx: [0], ageBandIdx: [0] };
+ expect(filterSegment(single, 1, 0)).toEqual([]);
+ });
+});
+
+describe("computeRaceHistogram (unchanged after extraction)", () => {
+ it("bins values and computes median + total", () => {
+ // Three 5-min-binnable swim-like values with binSize 300.
+ const result = computeRaceHistogram([300, 350, 620], 300);
+ expect(result.totalAthletes).toBe(3);
+ expect(result.medianSeconds).toBe(350);
+ // 300..600 has 2 (300,350); 600..900 has 1 (620)
+ const counts = result.bins.map((b) => b.count);
+ expect(counts).toEqual([2, 1]);
+ });
+ it("ignores zero/negative values", () => {
+ expect(computeRaceHistogram([0, -1], 300)).toEqual({
+ bins: [],
+ medianSeconds: 0,
+ totalAthletes: 0,
+ });
+ });
+ it("computeMedian averages the middle two for even counts", () =>
+ expect(computeMedian([10, 20, 30, 40])).toBe(25));
+});
diff --git a/app/src/lib/__tests__/race-segment.test.ts b/app/src/lib/__tests__/race-segment.test.ts
new file mode 100644
index 0000000..4e7a447
--- /dev/null
+++ b/app/src/lib/__tests__/race-segment.test.ts
@@ -0,0 +1,102 @@
+import { describe, it, expect } from "vitest";
+import { buildRaceSegmentData } from "../data";
+import type { AthleteResult } from "../types";
+
+// Minimal AthleteResult factory — buildRaceSegmentData only reads gender,
+// ageGroup, and the four discipline seconds. We test the pure transform with
+// synthetic data so the test doesn't depend on the build-time CSV corpus
+// (data/*.csv.gz), which is absent in the `npm test` CI job.
+function finisher(over: Partial): AthleteResult {
+ return {
+ gender: "Male",
+ ageGroup: "M35-39",
+ swimSeconds: 1000,
+ bikeSeconds: 2000,
+ runSeconds: 1500,
+ finishSeconds: 4500,
+ // Unused-by-transform fields, present to satisfy the type.
+ id: 0,
+ firstName: "",
+ lastName: "",
+ fullName: "",
+ bib: "",
+ city: "",
+ state: "",
+ country: "",
+ countryISO: "",
+ swimTime: "",
+ bikeTime: "",
+ runTime: "",
+ t1Time: "",
+ t2Time: "",
+ finishTime: "",
+ t1Seconds: 0,
+ t2Seconds: 0,
+ overallRank: 0,
+ genderRank: 0,
+ ageGroupRank: 0,
+ ...over,
+ } as AthleteResult;
+}
+
+describe("buildRaceSegmentData", () => {
+ const results: AthleteResult[] = [
+ finisher({ gender: "Male", ageGroup: "M35-39" }),
+ finisher({ gender: "Female", ageGroup: "F35-39" }),
+ finisher({ gender: "Male", ageGroup: "M18-24" }),
+ finisher({ gender: "Female", ageGroup: "FPRO" }),
+ finisher({ gender: "", ageGroup: "" }), // blank → -1 sentinel
+ ];
+ const data = buildRaceSegmentData(results);
+
+ it("returns equal-length parallel arrays", () => {
+ const n = results.length;
+ expect(data.swim.length).toBe(n);
+ expect(data.bike.length).toBe(n);
+ expect(data.run.length).toBe(n);
+ expect(data.finish.length).toBe(n);
+ expect(data.genderIdx.length).toBe(n);
+ expect(data.ageBandIdx.length).toBe(n);
+ });
+
+ it("orders gender labels Male before Female", () => {
+ expect(data.genders).toEqual(["Male", "Female"]);
+ });
+
+ it("derives gender-free age bands ordered numerically, PRO last", () => {
+ expect(data.ageBands).toEqual(["18-24", "35-39", "PRO"]);
+ for (const band of data.ageBands) {
+ expect(band).not.toMatch(/^[MF]\d/); // e.g. never "M35-39"
+ }
+ });
+
+ it("maps each finisher's indices into the label tables", () => {
+ // index 0: Male (0) / 35-39 (1)
+ expect(data.genderIdx[0]).toBe(0);
+ expect(data.ageBandIdx[0]).toBe(1);
+ // index 1: Female (1) / 35-39 (1)
+ expect(data.genderIdx[1]).toBe(1);
+ expect(data.ageBandIdx[1]).toBe(1);
+ // index 3: Female (1) / PRO (2)
+ expect(data.genderIdx[3]).toBe(1);
+ expect(data.ageBandIdx[3]).toBe(2);
+ });
+
+ it("assigns the -1 sentinel to blank gender/age group", () => {
+ expect(data.genderIdx[4]).toBe(-1);
+ expect(data.ageBandIdx[4]).toBe(-1);
+ // Blank values are excluded from the label tables entirely.
+ expect(data.genders).not.toContain("");
+ expect(data.ageBands).not.toContain("");
+ });
+
+ it("carries discipline seconds through in order", () => {
+ const custom = buildRaceSegmentData([
+ finisher({ swimSeconds: 111, bikeSeconds: 222, runSeconds: 333, finishSeconds: 444 }),
+ ]);
+ expect(custom.swim[0]).toBe(111);
+ expect(custom.bike[0]).toBe(222);
+ expect(custom.run[0]).toBe(333);
+ expect(custom.finish[0]).toBe(444);
+ });
+});
diff --git a/app/src/lib/data.ts b/app/src/lib/data.ts
index 3d0353a..1707af8 100644
--- a/app/src/lib/data.ts
+++ b/app/src/lib/data.ts
@@ -1,8 +1,19 @@
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, RaceStats } from "./types";
+import { AggregateStats, AthleteResult, AthleteSearchEntry, AgeGroupBreakdown, CourseStats, DisciplineStats, DistanceStats, GenderBreakdown, HistogramBin, HistogramData, LeaderboardEntry, RaceHistogramData, RaceSegmentData, RaceStats } from "./types";
import { getRaces } from "./races";
+import {
+ BIN_SIZES,
+ computeMedian,
+ computeRaceHistogram,
+ deriveAgeBand,
+ formatSecondsShort,
+ type Discipline,
+} from "./histogram";
+
+export type { Discipline } from "./histogram";
+export { BIN_SIZES, computeRaceHistogram } from "./histogram";
// Corpus-reading data access: everything here may reference data/*.csv.gz,
// data/histograms/* and data/athlete-index.tsv.gz, so any function whose
@@ -319,20 +330,6 @@ export function getStatsPageData(): StatsPageData {
};
}
-function formatSecondsShort(seconds: number): string {
- const h = Math.floor(seconds / 3600);
- const m = Math.floor((seconds % 3600) / 60);
- if (h > 0) return `${h}:${String(m).padStart(2, "0")}`;
- return `${m}m`;
-}
-
-function computeMedian(values: number[]): number {
- if (values.length === 0) return 0;
- const sorted = [...values].sort((a, b) => a - b);
- const mid = Math.floor(sorted.length / 2);
- return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
-}
-
export function computeHistogram(
allSeconds: number[],
athleteSeconds: number,
@@ -371,17 +368,6 @@ export function computeHistogram(
return { bins, athleteSeconds, athletePercentile, medianSeconds };
}
-export type Discipline = "swim" | "bike" | "run" | "finish" | "t1" | "t2";
-
-const BIN_SIZES: Record = {
- swim: 300, // 5-minute bins
- bike: 600, // 10-minute bins
- run: 600, // 10-minute bins
- finish: 600, // 10-minute bins
- t1: 60, // 1-minute bins
- t2: 60, // 1-minute bins
-};
-
function getSeconds(r: AthleteResult, discipline: Discipline): number {
switch (discipline) {
case "swim": return r.swimSeconds;
@@ -484,35 +470,6 @@ export function getDisciplineHistogram(
return computeHistogram(allSeconds, athleteSeconds, BIN_SIZES[discipline]);
}
-export function computeRaceHistogram(
- allSeconds: number[],
- binSize: number
-): RaceHistogramData {
- const valid = allSeconds.filter((s) => s > 0);
- if (valid.length === 0) {
- return { bins: [], medianSeconds: 0, totalAthletes: 0 };
- }
-
- const min = Math.floor(Math.min(...valid) / binSize) * binSize;
- const max = Math.ceil(Math.max(...valid) / binSize) * binSize;
-
- const bins: HistogramBin[] = [];
- for (let start = min; start < max; start += binSize) {
- const end = start + binSize;
- const count = valid.filter((s) => s >= start && s < end).length;
- bins.push({
- label: formatSecondsShort(start),
- rangeStart: start,
- rangeEnd: end,
- count,
- isAthlete: false,
- });
- }
-
- const medianSeconds = computeMedian(valid);
- return { bins, medianSeconds, totalAthletes: valid.length };
-}
-
export function getRaceStats(raceSlug: string): RaceStats {
const results = getAllResults(raceSlug);
@@ -649,3 +606,62 @@ export function getRaceStats(raceSlug: string): RaceStats {
histograms,
};
}
+
+// 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 {
+ const na = parseInt(a, 10);
+ const nb = parseInt(b, 10);
+ const aNum = !Number.isNaN(na);
+ const bNum = !Number.isNaN(nb);
+ if (aNum && bNum) return na - nb;
+ if (aNum) return -1;
+ if (bNum) return 1;
+ return a.localeCompare(b);
+}
+
+export function getRaceSegmentData(raceSlug: string): RaceSegmentData {
+ return buildRaceSegmentData(getAllResults(raceSlug));
+}
+
+// Pure transform from finisher results to the compact client payload. Split
+// from getRaceSegmentData so it can be tested with synthetic data — the CSV
+// corpus (data/*.csv.gz) is generated at build time and absent in the `npm
+// test` CI job, so a test that reads a real race gets empty arrays.
+export function buildRaceSegmentData(results: AthleteResult[]): RaceSegmentData {
+ // Build ordered label tables first.
+ const GENDER_ORDER: Record = { Male: 0, Female: 1 };
+ const genders = Array.from(new Set(results.map((r) => r.gender)))
+ .filter((g) => g)
+ .sort((a, b) => {
+ const ra = GENDER_ORDER[a] ?? 2;
+ const rb = GENDER_ORDER[b] ?? 2;
+ return ra !== rb ? ra - rb : a.localeCompare(b);
+ });
+ const ageBands = Array.from(
+ new Set(results.map((r) => deriveAgeBand(r.ageGroup)))
+ )
+ .filter((b) => b)
+ .sort(compareAgeBands);
+
+ const genderPos = new Map(genders.map((g, i) => [g, i]));
+ const bandPos = new Map(ageBands.map((b, i) => [b, i]));
+
+ const swim: number[] = [];
+ const bike: number[] = [];
+ const run: number[] = [];
+ const finish: number[] = [];
+ const genderIdx: number[] = [];
+ const ageBandIdx: number[] = [];
+
+ for (const r of results) {
+ swim.push(r.swimSeconds);
+ bike.push(r.bikeSeconds);
+ run.push(r.runSeconds);
+ finish.push(r.finishSeconds);
+ genderIdx.push(genderPos.get(r.gender) ?? -1);
+ ageBandIdx.push(bandPos.get(deriveAgeBand(r.ageGroup)) ?? -1);
+ }
+
+ return { swim, bike, run, finish, genderIdx, ageBandIdx, genders, ageBands };
+}
diff --git a/app/src/lib/histogram.ts b/app/src/lib/histogram.ts
new file mode 100644
index 0000000..db1654a
--- /dev/null
+++ b/app/src/lib/histogram.ts
@@ -0,0 +1,88 @@
+import type { HistogramBin, RaceHistogramData } from "./types";
+
+// Pure, corpus-independent histogram helpers. This module MUST stay free of
+// fs/path/zlib and any import that reaches lib/data.ts so client components can
+// import it without pulling the ~190MB data corpus into their bundle.
+
+export type Discipline = "swim" | "bike" | "run" | "finish" | "t1" | "t2";
+
+export const BIN_SIZES: Record = {
+ swim: 300, // 5-minute bins
+ bike: 600, // 10-minute bins
+ run: 600, // 10-minute bins
+ finish: 600, // 10-minute bins
+ t1: 60, // 1-minute bins
+ t2: 60, // 1-minute bins
+};
+
+export function formatSecondsShort(seconds: number): string {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ if (h > 0) return `${h}:${String(m).padStart(2, "0")}`;
+ return `${m}m`;
+}
+
+export function computeMedian(values: number[]): number {
+ if (values.length === 0) return 0;
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
+}
+
+export function computeRaceHistogram(
+ allSeconds: number[],
+ binSize: number
+): RaceHistogramData {
+ const valid = allSeconds.filter((s) => s > 0);
+ if (valid.length === 0) {
+ return { bins: [], medianSeconds: 0, totalAthletes: 0 };
+ }
+
+ const min = Math.floor(Math.min(...valid) / binSize) * binSize;
+ const max = Math.ceil(Math.max(...valid) / binSize) * binSize;
+
+ const bins: HistogramBin[] = [];
+ for (let start = min; start < max; start += binSize) {
+ const end = start + binSize;
+ const count = valid.filter((s) => s >= start && s < end).length;
+ bins.push({
+ label: formatSecondsShort(start),
+ rangeStart: start,
+ rangeEnd: end,
+ count,
+ isAthlete: false,
+ });
+ }
+
+ const medianSeconds = computeMedian(valid);
+ return { bins, medianSeconds, totalAthletes: valid.length };
+}
+
+// Strips the leading M/F gender prefix off an IRONMAN age-group code so
+// "M35-39" and "F35-39" collapse to a single "35-39" band ("MPRO" -> "PRO").
+// Values that don't match (free-form/odd age groups) pass through unchanged.
+export function deriveAgeBand(ageGroup: string): string {
+ const m = /^[MF](.+)$/.exec(ageGroup);
+ return m ? m[1] : ageGroup;
+}
+
+export interface SegmentArrays {
+ genderIdx: number[];
+ ageBandIdx: number[];
+}
+
+// Returns the indices of finishers matching the selected gender and age band.
+// A negative selector means "any".
+export function filterSegment(
+ data: SegmentArrays,
+ genderIdx: number,
+ ageBandIdx: number
+): number[] {
+ const out: number[] = [];
+ for (let i = 0; i < data.genderIdx.length; i++) {
+ if (genderIdx >= 0 && data.genderIdx[i] !== genderIdx) continue;
+ if (ageBandIdx >= 0 && data.ageBandIdx[i] !== ageBandIdx) continue;
+ out.push(i);
+ }
+ return out;
+}
diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts
index 45aec38..c350dda 100644
--- a/app/src/lib/types.ts
+++ b/app/src/lib/types.ts
@@ -136,6 +136,19 @@ export interface RaceHistogramData {
totalAthletes: number;
}
+export interface RaceSegmentData {
+ // Parallel per-finisher arrays (same length, same order).
+ swim: number[];
+ bike: number[];
+ run: number[];
+ finish: number[];
+ genderIdx: number[]; // index into `genders`
+ ageBandIdx: number[]; // index into `ageBands`
+ // Label tables, display-ordered.
+ genders: string[]; // e.g. ["Male", "Female"]
+ ageBands: string[]; // e.g. ["18-24", ..., "PRO"]
+}
+
export interface RaceStats {
totalFinishers: number;
disciplines: DisciplineStats[];