From 2c6deb04a2fe25b52a8131df0d6155425de30739 Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Wed, 15 Jul 2026 21:18:04 -0700 Subject: [PATCH 1/7] refactor: extract pure histogram helpers into lib/histogram --- app/src/lib/__tests__/histogram.test.ts | 58 ++++++++++++++++ app/src/lib/data.ts | 65 ++++-------------- app/src/lib/histogram.ts | 88 +++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 54 deletions(-) create mode 100644 app/src/lib/__tests__/histogram.test.ts create mode 100644 app/src/lib/histogram.ts 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/data.ts b/app/src/lib/data.ts index 3d0353a..b417472 100644 --- a/app/src/lib/data.ts +++ b/app/src/lib/data.ts @@ -3,6 +3,17 @@ 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 { 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); 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; +} From d1b627b55c2bb00da526bbd0914a648b697a9931 Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Wed, 15 Jul 2026 21:25:44 -0700 Subject: [PATCH 2/7] feat: add getRaceSegmentData for client-side race filtering --- app/src/lib/__tests__/race-segment.test.ts | 38 ++++++++++++++++ app/src/lib/data.ts | 50 +++++++++++++++++++++- app/src/lib/types.ts | 13 ++++++ 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 app/src/lib/__tests__/race-segment.test.ts 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..6a44cb8 --- /dev/null +++ b/app/src/lib/__tests__/race-segment.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { getRaceSegmentData } from "../data"; + +describe("getRaceSegmentData", () => { + const data = getRaceSegmentData("im703-swansea-2026"); + + it("returns equal-length parallel arrays", () => { + const n = data.swim.length; + expect(n).toBeGreaterThan(0); + 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("has index values within their label tables", () => { + // -1 is a valid sentinel for finishers whose raw gender/ageGroup was + // blank (excluded from the label tables by design — see + // getRaceSegmentData's note on unmapped entries). im703-swansea-2026 + // has 14 such finishers with blank gender, so -1 is expected here. + expect(Math.max(...data.genderIdx)).toBeLessThan(data.genders.length); + expect(Math.max(...data.ageBandIdx)).toBeLessThan(data.ageBands.length); + expect(Math.min(...data.genderIdx)).toBeGreaterThanOrEqual(-1); + expect(Math.min(...data.ageBandIdx)).toBeGreaterThanOrEqual(-1); + }); + + it("derives gender-free age bands (no leading M/F prefix)", () => { + for (const band of data.ageBands) { + expect(band).not.toMatch(/^[MF]\d/); // e.g. never "M35-39" + } + }); + + it("includes Male and Female gender labels", () => { + expect(data.genders).toContain("Male"); + expect(data.genders).toContain("Female"); + }); +}); diff --git a/app/src/lib/data.ts b/app/src/lib/data.ts index b417472..8de4c61 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, 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, @@ -606,3 +606,51 @@ 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 { + const results = getAllResults(raceSlug); + + // Build ordered label tables first. + const genders = Array.from(new Set(results.map((r) => r.gender))) + .filter((g) => g) + .sort(); + 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/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[]; From 8bff17bf33cfcdf43ea8fcfb534c24ecba350fd7 Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Wed, 15 Jul 2026 21:30:21 -0700 Subject: [PATCH 3/7] feat: add RaceDistributions filter component --- app/src/components/RaceDistributions.tsx | 127 +++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 app/src/components/RaceDistributions.tsx diff --git a/app/src/components/RaceDistributions.tsx b/app/src/components/RaceDistributions.tsx new file mode 100644 index 0000000..1f41b2b --- /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) => ( +
+ +
+ ))} +
+ )} +
+ ); +} From ea1b093d920d09190d2bb67c1e6cbad4085401e4 Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Wed, 15 Jul 2026 21:35:50 -0700 Subject: [PATCH 4/7] feat: filter race time distributions by gender and age group --- app/src/app/race/[slug]/page.tsx | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) 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 */} From 184c336b6cc1f566fc17cab0552ba8d90984746e Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Wed, 15 Jul 2026 21:43:34 -0700 Subject: [PATCH 5/7] fix: order gender filter Male-first and drop redundant select aria-labels Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GWYCWpC6wFtk4MH7E4gwDS --- app/src/components/RaceDistributions.tsx | 2 -- app/src/lib/data.ts | 7 ++++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/components/RaceDistributions.tsx b/app/src/components/RaceDistributions.tsx index 1f41b2b..cbcaf9d 100644 --- a/app/src/components/RaceDistributions.tsx +++ b/app/src/components/RaceDistributions.tsx @@ -59,7 +59,6 @@ export default function RaceDistributions({ data }: { data: RaceSegmentData }) { className={selectClass} value={genderIdx} onChange={(e) => setGenderIdx(Number(e.target.value))} - aria-label="Filter by gender" > {data.genders.map((g, i) => ( @@ -76,7 +75,6 @@ export default function RaceDistributions({ data }: { data: RaceSegmentData }) { className={selectClass} value={bandIdx} onChange={(e) => setBandIdx(Number(e.target.value))} - aria-label="Filter by age group" > {data.ageBands.map((b, i) => ( diff --git a/app/src/lib/data.ts b/app/src/lib/data.ts index 8de4c61..3b0685b 100644 --- a/app/src/lib/data.ts +++ b/app/src/lib/data.ts @@ -624,9 +624,14 @@ export function getRaceSegmentData(raceSlug: string): RaceSegmentData { const results = getAllResults(raceSlug); // 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(); + .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))) ) From 352297734b3d95e4a92aa2b782cc715ae623870e Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Wed, 15 Jul 2026 21:46:53 -0700 Subject: [PATCH 6/7] fix: associate filter labels via htmlFor for a clean accessible name Wrapping the setGenderIdx(Number(e.target.value))} @@ -67,11 +68,12 @@ export default function RaceDistributions({ data }: { data: RaceSegmentData }) { ))} - + -