From 019a816d0f21c81ac1255ff45b1c6ec5f9da81d4 Mon Sep 17 00:00:00 2001 From: Rootul Patel Date: Wed, 15 Jul 2026 21:30:54 -0700 Subject: [PATCH] feat: sync /races filters to the URL and make /courses navigable Addresses Reddit feedback on tritimes.org: - The /races filters (distance, year, search) now live in the URL, so a filtered view is shareable and Back from a race detail restores it. The URL is the single source of truth (derived during render), replacing the local useState mirror. - The /courses page gains a full, alphabetical, clickable list of every course per distance (previously only the top-10 charts were shown, and nothing was clickable). Chart bars are now clickable too. Both link to a pre-filtered /races view for that course. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DLbmbQZFx5Ffuu4LUhPj6p --- app/src/app/courses/course-charts.tsx | 17 +++-- app/src/app/courses/course-list.tsx | 54 ++++++++++++++++ app/src/app/courses/page.tsx | 3 +- app/src/app/races/page.tsx | 5 +- app/src/app/races/race-list.tsx | 34 ++++++++-- app/src/components/CourseBarChart.tsx | 20 +++++- app/src/lib/__tests__/races-url.test.ts | 86 +++++++++++++++++++++++++ app/src/lib/races-url.ts | 52 +++++++++++++++ 8 files changed, 257 insertions(+), 14 deletions(-) create mode 100644 app/src/app/courses/course-list.tsx create mode 100644 app/src/lib/__tests__/races-url.test.ts create mode 100644 app/src/lib/races-url.ts diff --git a/app/src/app/courses/course-charts.tsx b/app/src/app/courses/course-charts.tsx index 1a6c0de..5bd695f 100644 --- a/app/src/app/courses/course-charts.tsx +++ b/app/src/app/courses/course-charts.tsx @@ -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, @@ -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 @@ -79,13 +81,16 @@ export default function CourseCharts({ {DISCIPLINES.map((disc) => ( ))} + + ); } diff --git a/app/src/app/courses/course-list.tsx b/app/src/app/courses/course-list.tsx new file mode 100644 index 0000000..52d98e8 --- /dev/null +++ b/app/src/app/courses/course-list.tsx @@ -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 ( +
+

All Courses

+

+ {sorted.length} {distance} courses. Select one to see all of its races. +

+
    + {sorted.map((course) => ( +
  • + + + {course.displayName} + + + {formatTime(course.medianFinishSeconds)} + {course.editions} edition{course.editions !== 1 ? "s" : ""} + + +
  • + ))} +
+
+ ); +} diff --git a/app/src/app/courses/page.tsx b/app/src/app/courses/page.tsx index 48eabe6..ee9e239 100644 --- a/app/src/app/courses/page.tsx +++ b/app/src/app/courses/page.tsx @@ -16,7 +16,8 @@ export default function CoursesPage() {

Course Difficulty

- 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.

diff --git a/app/src/app/races/page.tsx b/app/src/app/races/page.tsx index c65e5d4..975b487 100644 --- a/app/src/app/races/page.tsx +++ b/app/src/app/races/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { getRaces, getGlobalStats } from "@/lib/races"; import RaceList from "./race-list"; @@ -22,7 +23,9 @@ export default function RacesPage() { return (

All Races

- + + +
); } diff --git a/app/src/app/races/race-list.tsx b/app/src/app/races/race-list.tsx index ba4bb6a..da9733e 100644 --- a/app/src/app/races/race-list.tsx +++ b/app/src/app/races/race-list.tsx @@ -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"); @@ -73,9 +75,27 @@ const RaceGrid = memo(function RaceGrid({ races }: { races: RaceInfo[] }) { }); export default function RaceList({ races }: { races: RaceInfo[] }) { - const [distance, setDistance] = useState("All"); - const [year, setYear] = useState("All"); - const [query, setQuery] = useState(""); + 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(initial.distance); + const [year, setYear] = useState(initial.year); + const [query, setQuery] = useState(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 @@ -115,7 +135,11 @@ export default function RaceList({ races }: { races: RaceInfo[] }) {
Distance {["All", "70.3", "140.6"].map((d) => ( - ))} diff --git a/app/src/components/CourseBarChart.tsx b/app/src/components/CourseBarChart.tsx index 97ebc32..12edb36 100644 --- a/app/src/components/CourseBarChart.tsx +++ b/app/src/components/CourseBarChart.tsx @@ -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 { @@ -24,6 +26,7 @@ interface Props { disciplineKey: DisciplineKey; color: string; label: string; + distance: "70.3" | "140.6"; } const SVG_WIDTH = 500; @@ -43,7 +46,9 @@ export default function CourseBarChart({ disciplineKey, color, label, + distance, }: Props) { + const router = useRouter(); const [tooltipIdx, setTooltipIdx] = useState(null); const svgRef = useRef(null); @@ -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 ( - + router.push(courseHref(distance, d.name))} + > + {/* Full-row hit area so the label and empty track are clickable too. */} + ); diff --git a/app/src/lib/__tests__/races-url.test.ts b/app/src/lib/__tests__/races-url.test.ts new file mode 100644 index 0000000..0444e66 --- /dev/null +++ b/app/src/lib/__tests__/races-url.test.ts @@ -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"); + }); +}); diff --git a/app/src/lib/races-url.ts b/app/src/lib/races-url.ts new file mode 100644 index 0000000..7fe07cf --- /dev/null +++ b/app/src/lib/races-url.ts @@ -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 })}`; +}