Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 5 additions & 21 deletions app/src/app/race/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div className="h-52 bg-gray-800 rounded animate-pulse" />,
});

// Generate on demand — too many races to pre-render at build time.
export async function generateStaticParams() {
return [];
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -129,23 +126,10 @@ export default async function RacePage({ params }: PageProps) {
</div>
</section>

{/* Time distributions */}
{/* Time distributions (filterable by gender + age group) */}
<section className="mb-10">
<h2 className="text-xl font-bold text-white mb-4">Time Distributions</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{(["swim", "bike", "run", "finish"] as const).map((key) => {
const label = key === "finish" ? "Total" : key.charAt(0).toUpperCase() + key.slice(1);
return (
<div key={key} className="bg-gray-900 rounded-xl border border-gray-700 p-6">
<RaceHistogram
data={stats.histograms[key]}
color={DISCIPLINE_COLORS[label]}
label={`${label} Distribution`}
/>
</div>
);
})}
</div>
<RaceDistributions data={segmentData} />
</section>

{/* Demographics */}
Expand Down
127 changes: 127 additions & 0 deletions app/src/components/RaceDistributions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
{/* Filter controls */}
<div className="flex flex-wrap items-center gap-3 mb-6">
<div className="flex flex-col gap-1 text-xs text-gray-400">
<label htmlFor="race-dist-gender">Gender</label>
<select
id="race-dist-gender"
className={selectClass}
value={genderIdx}
onChange={(e) => setGenderIdx(Number(e.target.value))}
>
<option value={ANY}>All</option>
{data.genders.map((g, i) => (
<option key={g} value={i}>
{g}
</option>
))}
</select>
</div>

<div className="flex flex-col gap-1 text-xs text-gray-400">
<label htmlFor="race-dist-age-group">Age group</label>
<select
id="race-dist-age-group"
className={selectClass}
value={bandIdx}
onChange={(e) => setBandIdx(Number(e.target.value))}
>
<option value={ANY}>All</option>
{data.ageBands.map((b, i) => (
<option key={b} value={i}>
{b}
</option>
))}
</select>
</div>

{isFiltered && (
<div className="flex items-center gap-3 self-end pb-2">
<span className="text-sm text-gray-400">
{indices.length.toLocaleString()} of {total.toLocaleString()} finishers
</span>
<button
onClick={reset}
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
>
Reset
</button>
</div>
)}
</div>

{indices.length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-700 p-10 text-center text-gray-400">
No finishers match this filter.
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{histograms.map((h) => (
<div
key={h.key}
className="bg-gray-900 rounded-xl border border-gray-700 p-6"
>
<RaceHistogram
data={h.histogram}
color={DISCIPLINE_COLORS[h.label]}
label={`${h.label} Distribution`}
/>
</div>
))}
</div>
)}
</div>
);
}
58 changes: 58 additions & 0 deletions app/src/lib/__tests__/histogram.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
102 changes: 102 additions & 0 deletions app/src/lib/__tests__/race-segment.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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);
});
});
Loading