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[];