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
17 changes: 11 additions & 6 deletions app/src/app/courses/course-charts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
import type { CourseStats } from "@/lib/types";
import { DISCIPLINE_COLORS } from "@/lib/colors";
import { isProCourse } from "@/components/chart-utils";
import CourseList from "./course-list";

const CourseBarChart = dynamic(() => import("@/components/CourseBarChart"), {
ssr: false,
Expand Down Expand Up @@ -46,12 +47,13 @@ export default function CourseCharts({
}) {
const [distance, setDistance] = useState<"70.3" | "140.6">("70.3");

const filtered = courses.filter(
(c) =>
c.distance === distance &&
!c.course.includes("world-championship") &&
!isProCourse(c.displayName)
// Pro-only scrape artifacts never belong in either view.
const forDistance = courses.filter(
(c) => c.distance === distance && !isProCourse(c.displayName)
);
// Charts exclude world championships so the difficulty comparison stays fair;
// the full list below still includes them.
const chartCourses = forDistance.filter((c) => !c.course.includes("world-championship"));

const btnClass = (active: boolean) =>
active
Expand Down Expand Up @@ -79,13 +81,16 @@ export default function CourseCharts({
{DISCIPLINES.map((disc) => (
<CourseBarChart
key={disc.key}
courses={filtered}
courses={chartCourses}
disciplineKey={disc.key}
color={disc.color}
label={disc.label}
distance={distance}
/>
))}
</div>

<CourseList courses={forDistance} distance={distance} />
</>
);
}
54 changes: 54 additions & 0 deletions app/src/app/courses/course-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use client";

import Link from "next/link";
import type { CourseStats } from "@/lib/types";
import { courseHref } from "@/lib/races-url";

function formatTime(seconds: number): string {
if (seconds <= 0) return "—";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}:${String(m).padStart(2, "0")}`;
}

// A complete, alphabetical list of every course for the selected distance.
// Unlike the top-10 charts above, this is the exhaustive, keyboard-navigable
// path from a course to its races.
export default function CourseList({
courses,
distance,
}: {
courses: CourseStats[];
distance: "70.3" | "140.6";
}) {
const sorted = [...courses].sort((a, b) => a.displayName.localeCompare(b.displayName));

if (sorted.length === 0) return null;

return (
<section className="mt-12">
<h2 className="text-xl font-bold text-white mb-1">All Courses</h2>
<p className="text-sm text-gray-500 mb-4">
{sorted.length} {distance} courses. Select one to see all of its races.
</p>
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{sorted.map((course) => (
<li key={course.course}>
<Link
href={courseHref(distance, course.displayName)}
className="group flex items-baseline justify-between gap-3 px-4 py-3 border border-gray-700/80 rounded-lg bg-gray-900 transition-colors duration-200 hover:border-gray-600 hover:bg-gray-800/80"
>
<span className="font-medium text-white group-hover:text-blue-300 transition-colors leading-tight">
{course.displayName}
</span>
<span className="shrink-0 text-right text-xs text-gray-500 leading-tight">
<span className="block text-gray-400">{formatTime(course.medianFinishSeconds)}</span>
{course.editions} edition{course.editions !== 1 ? "s" : ""}
</span>
</Link>
</li>
))}
</ul>
</section>
);
}
3 changes: 2 additions & 1 deletion app/src/app/courses/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export default function CoursesPage() {
<header className="mb-8">
<h1 className="text-3xl font-bold text-white">Course Difficulty</h1>
<p className="text-gray-400 mt-1">
Top 10 fastest courses per discipline, ranked by median time.
The 10 fastest courses per discipline, ranked by median time. Select any course to see all
of its races.
</p>
</header>
<CourseCharts courses={courses} />
Expand Down
5 changes: 4 additions & 1 deletion app/src/app/races/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { getRaces, getGlobalStats } from "@/lib/races";
import RaceList from "./race-list";

Expand All @@ -22,7 +23,9 @@ export default function RacesPage() {
return (
<main className="max-w-6xl w-full mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-white mb-8">All Races</h1>
<RaceList races={races} />
<Suspense fallback={null}>
<RaceList races={races} />
</Suspense>
</main>
);
}
34 changes: 29 additions & 5 deletions app/src/app/races/race-list.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"use client";

import { memo, useDeferredValue, useMemo, useState } from "react";
import { memo, useDeferredValue, useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import type { RaceInfo } from "@/lib/types";
import { getCountryFlag } from "@/lib/flags";
import { getRaceLocation } from "@/lib/raceLocation";
import { filterRaces } from "@/lib/race-filter";
import { filtersToQueryString, parseFiltersFromParams } from "@/lib/races-url";

function formatDate(iso: string): string {
const date = new Date(iso + "T00:00:00");
Expand Down Expand Up @@ -73,9 +75,27 @@ const RaceGrid = memo(function RaceGrid({ races }: { races: RaceInfo[] }) {
});

export default function RaceList({ races }: { races: RaceInfo[] }) {
const [distance, setDistance] = useState<string>("All");
const [year, setYear] = useState<string>("All");
const [query, setQuery] = useState<string>("");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();

// Seed the filters from the URL so shared links open pre-filtered and Back
// from a race detail restores the view — RaceList remounts on Back and
// re-reads the URL here. Local state stays the urgent source of truth, which
// keeps the search input snappy while the deferred grid re-renders (see below).
const initial = parseFiltersFromParams(new URLSearchParams(searchParams.toString()));
const [distance, setDistance] = useState<string>(initial.distance);
const [year, setYear] = useState<string>(initial.year);
const [query, setQuery] = useState<string>(initial.query);

// Mirror the active filters into the URL so a filtered view is shareable.
// replace (not push) keeps per-keystroke states out of browser history, so
// there is no intra-page history to restore — hence no URL→state sync needed.
useEffect(() => {
const qs = filtersToQueryString({ distance, year, query });
if (qs === searchParams.toString()) return;
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}, [distance, year, query, pathname, router, searchParams]);

// Filter selections stay urgent so buttons/inputs update the instant they're
// clicked. The expensive list is derived from *deferred* copies, so React
Expand Down Expand Up @@ -115,7 +135,11 @@ export default function RaceList({ races }: { races: RaceInfo[] }) {
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider mr-1">Distance</span>
{["All", "70.3", "140.6"].map((d) => (
<button key={d} onClick={() => setDistance(d)} className={btnClass(distance === d)}>
<button
key={d}
onClick={() => setDistance(d)}
className={btnClass(distance === d)}
>
{d}
</button>
))}
Expand Down
20 changes: 19 additions & 1 deletion app/src/components/CourseBarChart.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"use client";

import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import type { CourseStats } from "@/lib/types";
import { courseHref } from "@/lib/races-url";
import { truncateLabel } from "./chart-utils";

function formatTime(seconds: number): string {
Expand All @@ -24,6 +26,7 @@ interface Props {
disciplineKey: DisciplineKey;
color: string;
label: string;
distance: "70.3" | "140.6";
}

const SVG_WIDTH = 500;
Expand All @@ -43,7 +46,9 @@ export default function CourseBarChart({
disciplineKey,
color,
label,
distance,
}: Props) {
const router = useRouter();
const [tooltipIdx, setTooltipIdx] = useState<number | null>(null);
const svgRef = useRef<SVGSVGElement>(null);

Expand Down Expand Up @@ -119,7 +124,19 @@ export default function CourseBarChart({
const y = MARGIN.top + i * BAR_SPACING + (BAR_SPACING - BAR_HEIGHT) / 2;
const w = maxSeconds > 0 ? (d.seconds / maxSeconds) * plotWidth : 0;
return (
<g key={i}>
<g
key={i}
style={{ cursor: "pointer" }}
onClick={() => router.push(courseHref(distance, d.name))}
>
{/* Full-row hit area so the label and empty track are clickable too. */}
<rect
x={0}
y={y - (BAR_SPACING - BAR_HEIGHT) / 2}
width={SVG_WIDTH}
height={BAR_SPACING}
fill="transparent"
/>
<text
x={MARGIN.left - 5}
y={y + BAR_HEIGHT / 2 + 4}
Expand All @@ -136,6 +153,7 @@ export default function CourseBarChart({
height={BAR_HEIGHT}
rx="4"
fill={color}
opacity={tooltipIdx === i ? 0.8 : 1}
/>
</g>
);
Expand Down
86 changes: 86 additions & 0 deletions app/src/lib/__tests__/races-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import {
filtersToQueryString,
parseFiltersFromParams,
courseHref,
} from "../races-url";

describe("filtersToQueryString", () => {
it("returns an empty string when all filters are at their defaults", () => {
expect(filtersToQueryString({ distance: "All", year: "All", query: "" })).toBe("");
});

it("treats a whitespace-only query as empty", () => {
expect(filtersToQueryString({ distance: "All", year: "All", query: " " })).toBe("");
});

it("serializes a non-default distance", () => {
expect(filtersToQueryString({ distance: "140.6", year: "All", query: "" })).toBe(
"distance=140.6",
);
});

it("serializes a non-default year", () => {
expect(filtersToQueryString({ distance: "All", year: "2024", query: "" })).toBe("year=2024");
});

it("serializes and URL-encodes the query under the q param", () => {
expect(filtersToQueryString({ distance: "All", year: "All", query: "São Paulo" })).toBe(
"q=S%C3%A3o+Paulo",
);
});

it("stores the query verbatim so a controlled input keeps mid-word spaces", () => {
expect(filtersToQueryString({ distance: "All", year: "All", query: "new york" })).toBe(
"q=new+york",
);
});

it("serializes all non-default filters in distance, year, q order", () => {
expect(
filtersToQueryString({ distance: "140.6", year: "2024", query: "wisconsin" }),
).toBe("distance=140.6&year=2024&q=wisconsin");
});
});

describe("parseFiltersFromParams", () => {
it("falls back to defaults when no params are present", () => {
expect(parseFiltersFromParams(new URLSearchParams(""))).toEqual({
distance: "All",
year: "All",
query: "",
});
});

it("reads distance, year, and q", () => {
expect(
parseFiltersFromParams(new URLSearchParams("distance=140.6&year=2024&q=wisconsin")),
).toEqual({ distance: "140.6", year: "2024", query: "wisconsin" });
});

it("ignores an invalid distance value", () => {
expect(parseFiltersFromParams(new URLSearchParams("distance=marathon")).distance).toBe("All");
});

it("decodes an encoded query", () => {
expect(parseFiltersFromParams(new URLSearchParams("q=S%C3%A3o+Paulo")).query).toBe(
"São Paulo",
);
});

it("round-trips with filtersToQueryString", () => {
const filters = { distance: "70.3", year: "2023", query: "lake placid" };
const restored = parseFiltersFromParams(new URLSearchParams(filtersToQueryString(filters)));
expect(restored).toEqual(filters);
});
});

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");
});
});
52 changes: 52 additions & 0 deletions app/src/lib/races-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Pure (de)serialization between the /races filter state and the URL query
// string. Keeping this logic out of React makes the RaceList URL-sync wiring
// thin and lets us unit-test the tricky parts. Mirrors the pure-helper pattern
// of race-filter.ts.

export interface RaceUrlFilters {
/** "All" | "70.3" | "140.6" */
distance: string;
/** "All" or a 4-digit year */
year: string;
/** Free-text query matched against race name and location */
query: string;
}

const VALID_DISTANCES = ["70.3", "140.6"];

/**
* Serialize filters to a query string (no leading "?"), omitting any filter
* that sits at its default ("All" distance/year, empty or whitespace-only
* query). The query is stored verbatim (not trimmed) so it can back a
* controlled text input without eating mid-word spaces. Params are emitted in
* distance, year, q order.
*/
export function filtersToQueryString(filters: RaceUrlFilters): string {
const params = new URLSearchParams();
if (filters.distance !== "All") params.set("distance", filters.distance);
if (filters.year !== "All") params.set("year", filters.year);
if (filters.query.trim()) params.set("q", filters.query);
return params.toString();
}

/**
* Read filters from URL search params, falling back to defaults for missing or
* invalid values. Accepts a URLSearchParams (or the ReadonlyURLSearchParams
* returned by Next's useSearchParams).
*/
export function parseFiltersFromParams(params: URLSearchParams): RaceUrlFilters {
const rawDistance = params.get("distance");
const distance = rawDistance && VALID_DISTANCES.includes(rawDistance) ? rawDistance : "All";
const year = params.get("year") ?? "All";
const query = params.get("q") ?? "";
return { distance, year, query };
}

/**
* 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.
*/
export function courseHref(distance: string, displayName: string): string {
return `/races?${filtersToQueryString({ distance, year: "All", query: displayName })}`;
}