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
21 changes: 21 additions & 0 deletions app/src/app/athlete/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getAthleteProfile } from "@/lib/athlete-shards";
import { getCountryFlagISO } from "@/lib/flags";
Expand All @@ -16,6 +17,26 @@ interface PageProps {
params: Promise<{ slug: string }>;
}

// The duplicate getAthleteProfile call (metadata + page) is served from the
// in-memory shard cache, so it costs one shard fetch, not two.
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const profile = await getAthleteProfile(slug);
if (!profile) return {};

const name = formatAthleteName(profile.fullName);
const raceCount = profile.races.length;
const title = `${name} — Triathlon Race History`;
const description = `${name}${profile.country ? ` (${profile.country})` : ""} has ${raceCount} IRONMAN and IRONMAN 70.3 ${raceCount === 1 ? "result" : "results"} on TriTimes. View finish times, splits, and percentiles for every race.`;

return {
title,
description,
alternates: { canonical: `/athlete/${slug}` },
openGraph: { title, description, url: `/athlete/${slug}` },
};
}

export default async function AthletePage({ params }: PageProps) {
const { slug } = await params;
const profile = await getAthleteProfile(slug);
Expand Down
5 changes: 3 additions & 2 deletions app/src/app/courses/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { getCourseStats } from "@/lib/data";
import CourseCharts from "./course-charts";

export const metadata = {
title: "Course Difficulty | TriTimes",
title: "IRONMAN & 70.3 Course Difficulty",
description:
"Compare IRONMAN and IRONMAN 70.3 course difficulty based on median finish times across all race editions.",
"Which IRONMAN courses are fastest? Compare IRONMAN and IRONMAN 70.3 course difficulty based on median finish times across all race editions.",
alternates: { canonical: "/courses" },
};

export default function CoursesPage() {
Expand Down
18 changes: 16 additions & 2 deletions app/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,23 @@ const geistMono = Geist_Mono({
});

export const metadata: Metadata = {
title: "TriTimes — Triathlon Times",
metadataBase: new URL("https://tritimes.org"),
title: {
default: "TriTimes — IRONMAN & 70.3 Triathlon Results",
template: "%s | TriTimes",
},
description:
"View your IronMan 70.3 triathlon results with statistical distributions for swim, bike, run, and total time.",
"Look up IRONMAN and IRONMAN 70.3 race results with full-field time distributions. See your percentile for swim, bike, run, and overall across 1,400+ races since 2002.",
openGraph: {
siteName: "TriTimes",
type: "website",
url: "https://tritimes.org",
},
// Set GOOGLE_SITE_VERIFICATION in Vercel to verify the site in Google
// Search Console without committing the token.
verification: {
google: process.env.GOOGLE_SITE_VERIFICATION,
},
};

export default function RootLayout({
Expand Down
13 changes: 13 additions & 0 deletions app/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import GlobalSearchBar from "@/components/GlobalSearchBar";

const WEBSITE_JSON_LD = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "TriTimes",
url: "https://tritimes.org",
description:
"IRONMAN and IRONMAN 70.3 race results with full-field time distributions and percentiles for swim, bike, and run.",
};

export default function Home() {
return (
<main className="flex-1">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(WEBSITE_JSON_LD) }}
/>
{/* Hero section */}
<section className="relative">
{/* Background gradient */}
Expand Down
34 changes: 34 additions & 0 deletions app/src/app/race/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Link from "next/link";
import { getRaceBySlug, getRaceStats } from "@/lib/data";
Expand Down Expand Up @@ -25,6 +26,23 @@ interface PageProps {
params: Promise<{ slug: string }>;
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const race = getRaceBySlug(slug);
if (!race) return {};

const location = getRaceLocation(race);
const title = `${race.name} — Results & Time Distributions`;
const description = `Full results for ${race.name}${location ? ` in ${location}` : ""}: ${race.finishers.toLocaleString()} finishers, fastest and median splits, and time distributions for swim, bike, and run.`;

return {
title,
description,
alternates: { canonical: `/race/${slug}` },
openGraph: { title, description, url: `/race/${slug}` },
};
}

function genderColor(gender: string): string {
if (gender === "Male") return "#3b82f6";
if (gender === "Female") return "#ec4899";
Expand All @@ -46,8 +64,24 @@ export default async function RacePage({ params }: PageProps) {

const location = getRaceLocation(race);

const jsonLd = {
"@context": "https://schema.org",
"@type": "SportsEvent",
name: race.name,
sport: "Triathlon",
eventStatus: "https://schema.org/EventScheduled",
...(race.date ? { startDate: race.date } : {}),
...(location ? { location: { "@type": "Place", name: location } } : {}),
url: `https://tritimes.org/race/${slug}`,
};

return (
<main className="max-w-6xl w-full mx-auto px-4 py-8">
<script
type="application/ld+json"
// Escape "<" so race names can never close the script tag early.
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<header className="mb-8">
<h1 className="text-3xl font-bold text-white">{race.name}</h1>
<p className="text-gray-400 mt-1">
Expand Down
21 changes: 21 additions & 0 deletions app/src/app/race/[slug]/result/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { notFound } from "next/navigation";
import Link from "next/link";
Expand All @@ -21,6 +22,26 @@ interface PageProps {
params: Promise<{ slug: string; id: string }>;
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug, id } = await params;
const race = getRaceBySlug(slug);
const athlete = race ? getAthleteById(slug, Number(id)) : null;
if (!race || !athlete) return {};

const name = formatAthleteName(athlete.fullName);
const title = `${name} — ${race.name} Result`;
const description = athlete.finishTime
? `${name} finished ${race.name} in ${athlete.finishTime}${athlete.ageGroup ? ` (${athlete.ageGroup})` : ""}. Swim ${athlete.swimTime}, bike ${athlete.bikeTime}, run ${athlete.runTime}. See percentiles and full-field time distributions.`
: `${name}'s result at ${race.name}. See splits, percentiles, and full-field time distributions.`;

return {
title,
description,
alternates: { canonical: `/race/${slug}/result/${id}` },
openGraph: { title, description, url: `/race/${slug}/result/${id}` },
};
}

export default async function ResultPage({ params }: PageProps) {
const { slug, id } = await params;
const race = getRaceBySlug(slug);
Expand Down
15 changes: 14 additions & 1 deletion app/src/app/races/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { getRaces } from "@/lib/races";
import type { Metadata } from "next";
import { getRaces, getGlobalStats } from "@/lib/races";
import RaceList from "./race-list";

export const revalidate = false;

export function generateMetadata(): Metadata {
const { raceCount, totalResults } = getGlobalStats();
const description = `Browse ${raceCount.toLocaleString()} IRONMAN and IRONMAN 70.3 races with ${totalResults.toLocaleString()} finisher results. Filter by distance and year, then dive into time distributions for any race.`;

return {
title: "All IRONMAN & 70.3 Races",
description,
alternates: { canonical: "/races" },
openGraph: { title: "All IRONMAN & 70.3 Races", description, url: "/races" },
};
}

export default function RacesPage() {
const races = getRaces();

Expand Down
15 changes: 9 additions & 6 deletions app/src/app/robots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@ import type { MetadataRoute } from "next";

const SITE_URL = "https://tritimes.org";

// AI training crawlers that have produced spikes of unique-URL traffic on
// data-heavy sites. Blocking them keeps ISR writes bounded and the site's
// AI *training* crawlers that have produced spikes of unique-URL traffic on
// data-heavy sites. Blocking them keeps render spend bounded and the site's
// content out of unattributed model training corpora.
//
// Deliberately NOT blocked: AI *search* and user-triggered agents
// (OAI-SearchBot, ChatGPT-User, PerplexityBot, Claude-Web). They power cited
// answers in ChatGPT Search / Perplexity — a referral channel, not a training
// corpus — and their fetch volume is demand-driven rather than a full-graph
// crawl. Result pages render dynamically with no ISR-write cost, so the
// original spend concern no longer applies to them.
const BLOCKED_AI_AGENTS = [
"GPTBot",
"ChatGPT-User",
"OAI-SearchBot",
"ClaudeBot",
"anthropic-ai",
"Claude-Web",
"PerplexityBot",
"CCBot",
"Bytespider",
"Amazonbot",
Expand Down
5 changes: 3 additions & 2 deletions app/src/app/stats/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { getCountryFlagISO } from "@/lib/flags";
import { formatAthleteName } from "@/lib/format";

export const metadata = {
title: "Stats | TriTimes",
title: "IRONMAN & 70.3 Stats",
description:
"Aggregate statistics across all IRONMAN and IRONMAN 70.3 races tracked by TriTimes.",
"Aggregate statistics across all IRONMAN and IRONMAN 70.3 races tracked by TriTimes: participation trends, finish times, and standout performances.",
alternates: { canonical: "/stats" },
};

function formatSeconds(seconds: number): string {
Expand Down