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
41 changes: 41 additions & 0 deletions app/src/app/faq/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { FAQ_ITEMS, buildFaqJsonLd } from "@/lib/faq";

export const revalidate = false;

export const metadata: Metadata = {
title: "FAQ",
description:
"Frequently asked questions about TriTimes — where the race data comes from, whether it's affiliated with IRONMAN, and how to report issues.",
alternates: { canonical: "/faq" },
openGraph: {
title: "FAQ",
description:
"Frequently asked questions about TriTimes — where the race data comes from and how it works.",
url: "/faq",
},
};

export default function FaqPage() {
return (
<main className="max-w-3xl w-full mx-auto px-4 py-8">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(buildFaqJsonLd()) }}
/>
<h1 className="text-3xl font-bold text-white mb-8">
Frequently Asked Questions
</h1>
<dl className="space-y-8">
{FAQ_ITEMS.map((item) => (
<div key={item.question}>
<dt className="text-lg font-semibold text-white mb-2">
{item.question}
</dt>
<dd className="text-gray-400 leading-relaxed">{item.answer}</dd>
</div>
))}
</dl>
</main>
);
}
6 changes: 6 additions & 0 deletions app/src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ export default function Footer() {
>
Stats
</Link>
<Link
href="/faq"
className="text-sm text-gray-400 hover:text-white transition-colors"
>
FAQ
</Link>
<a
href="https://docs.google.com/forms/d/e/1FAIpQLScavYEE5U8S1Cx2OyLxFpDMPgNh5gcZh_VsbLawOBNkdYtNOQ/viewform"
target="_blank"
Expand Down
40 changes: 40 additions & 0 deletions app/src/lib/__tests__/faq.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { FAQ_ITEMS, buildFaqJsonLd } from "../faq";

describe("FAQ_ITEMS", () => {
it("includes a question about where the data comes from", () => {
const item = FAQ_ITEMS.find(
(i) => /data/i.test(i.question) && /(come|source|get|scrap)/i.test(i.question),
);
expect(item).toBeDefined();
// The honest answer names the Competitor.com results API as the source.
expect(item!.answer.toLowerCase()).toContain("competitor.com");
});

it("has a non-empty question and answer for every item", () => {
expect(FAQ_ITEMS.length).toBeGreaterThan(0);
for (const item of FAQ_ITEMS) {
expect(item.question.trim().length).toBeGreaterThan(0);
expect(item.answer.trim().length).toBeGreaterThan(0);
}
});
});

describe("buildFaqJsonLd", () => {
it("emits schema.org FAQPage structured data", () => {
const jsonLd = buildFaqJsonLd();
expect(jsonLd["@context"]).toBe("https://schema.org");
expect(jsonLd["@type"]).toBe("FAQPage");
});

it("emits one Question entity per FAQ item with a text Answer", () => {
const jsonLd = buildFaqJsonLd();
expect(jsonLd.mainEntity).toHaveLength(FAQ_ITEMS.length);
jsonLd.mainEntity.forEach((entity, i) => {
expect(entity["@type"]).toBe("Question");
expect(entity.name).toBe(FAQ_ITEMS[i].question);
expect(entity.acceptedAnswer["@type"]).toBe("Answer");
expect(entity.acceptedAnswer.text).toBe(FAQ_ITEMS[i].answer);
});
});
});
56 changes: 56 additions & 0 deletions app/src/lib/faq.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Frequently-asked-questions content for the /faq page.
//
// Q&A data and the schema.org FAQPage builder live here (not in the page
// component) so they can be unit-tested and so the page stays a thin renderer.
// This module intentionally has no dependency on the heavy corpus in data.ts.

export interface FaqItem {
question: string;
answer: string;
}

export const FAQ_ITEMS: FaqItem[] = [
{
question: "Where does TriTimes get its data?",
answer:
"TriTimes reads results from the same Competitor.com results API (labs-v2.competitor.com) that powers IRONMAN's own official results pages. It is not HTML-scraping the results table, and it is not a public or documented third-party API — it's the same undocumented endpoint the official site itself uses to load results. For each event we read the ironman.com results page once to find that event's ID, then fetch its results as JSON.",
},
{
question: "Is TriTimes affiliated with IRONMAN?",
answer:
"No. TriTimes is an independent, unofficial project and is not affiliated with, endorsed by, or sponsored by The IRONMAN Group or Competitor.com. All race data belongs to its respective owners.",
},
{
question: "Why is a result missing or incorrect?",
answer:
"TriTimes mirrors what the official results publish, so missing splits, misspelled names, or wrong age groups usually originate in the source data. If a result looks wrong or a race is missing, let us know through the Feedback link in the footer and we'll take a look.",
},
];

export interface FaqJsonLd {
"@context": "https://schema.org";
"@type": "FAQPage";
mainEntity: Array<{
"@type": "Question";
name: string;
acceptedAnswer: {
"@type": "Answer";
text: string;
};
}>;
}

export function buildFaqJsonLd(): FaqJsonLd {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: FAQ_ITEMS.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: {
"@type": "Answer",
text: item.answer,
},
})),
};
}