diff --git a/scripts/check-data-freshness.mjs b/scripts/check-data-freshness.mjs
index 7f3ac04..06e8db8 100644
--- a/scripts/check-data-freshness.mjs
+++ b/scripts/check-data-freshness.mjs
@@ -2,6 +2,11 @@
import { readFileSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
import { isRealIsoDate } from "./lib/data-validation.mjs";
+import {
+ isVerificationExpired,
+ verificationExpiry,
+ verificationWindowMonths,
+} from "./lib/verification-window.mjs";
// Directories checked when --data-dir is not given. --data-dir overrides this
// list with a single directory, which is how tests point the check at a temp
@@ -50,6 +55,8 @@ function cycleHasLapsed(record, today) {
}
function checkDir(dataDir, today) {
+ // Competition records all share the 6-month window; places depend on their type.
+ const isCompetitionDir = /[\\/]competitions$/.test(dataDir);
const files = readdirSync(dataDir)
.filter((file) => file.endsWith(".json"))
.sort();
@@ -108,6 +115,27 @@ function checkDir(dataDir, today) {
}
}
+ // A verification only counts for 12 months (6 for exam centres and competitions).
+ if (record.verified !== undefined && record.verified !== null) {
+ const on = record.verified.on;
+ const months = verificationWindowMonths({
+ type: record.type ?? file.replace(/\.json$/, ""),
+ competition: isCompetitionDir,
+ });
+ if (typeof on !== "string" || !isRealIsoDate(on)) {
+ console.error(
+ `ERROR ${recordLoc}: verified.on must be a real ISO date (YYYY-MM-DD), got "${on}"`,
+ );
+ totalErrors++;
+ } else if (isVerificationExpired(on, months, today)) {
+ console.error(
+ `ERROR ${recordLoc}: verified.on ${on} is past its ${months}-month window (expired ${verificationExpiry(on, months)}); re-verify this record for ${today}`,
+ );
+ totalErrors++;
+ }
+ dated = true;
+ }
+
const lapseReason = cycleHasLapsed(record, today);
if (lapseReason) {
dated = true;
diff --git a/scripts/lib/verification-window.mjs b/scripts/lib/verification-window.mjs
new file mode 100644
index 0000000..ce70813
--- /dev/null
+++ b/scripts/lib/verification-window.mjs
@@ -0,0 +1,71 @@
+// How long a `verified` block stays valid, shared by the verified badge
+// (src/lib/verification.ts) and scripts/check-data-freshness.mjs so the two
+// can never disagree. The windows are documented in data/CONTRIBUTING.md and
+// data/competitions/CONTRIBUTING.md.
+
+/** Exam centres change their dates and addresses often, so they expire sooner. */
+const SHORT_WINDOW_PLACE_TYPES = new Set(["sat_centre", "foreign_lang_exam_centre"]);
+
+export const DEFAULT_WINDOW_MONTHS = 12;
+export const SHORT_WINDOW_MONTHS = 6;
+
+/**
+ * Months a verification stays valid: 6 for exam centres and for competitions,
+ * 12 for every other place.
+ *
+ * @param {{ type?: string, competition?: boolean }} record
+ * @returns {number}
+ */
+export function verificationWindowMonths({ type, competition = false }) {
+ if (competition) return SHORT_WINDOW_MONTHS;
+ return type !== undefined && SHORT_WINDOW_PLACE_TYPES.has(type)
+ ? SHORT_WINDOW_MONTHS
+ : DEFAULT_WINDOW_MONTHS;
+}
+
+/**
+ * The last day a verification made on `onIso` (YYYY-MM-DD) is still valid:
+ * the same day `months` later, or the end of that month when it is shorter
+ * (31 August plus six months is 28 or 29 February). Returns null for a date
+ * that is not a real ISO date.
+ *
+ * @param {string} onIso
+ * @param {number} months
+ * @returns {string | null}
+ */
+export function verificationExpiry(onIso, months) {
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(onIso);
+ if (!match) return null;
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
+ const check = new Date(Date.UTC(year, month - 1, day));
+ if (
+ check.getUTCFullYear() !== year ||
+ check.getUTCMonth() !== month - 1 ||
+ check.getUTCDate() !== day
+ ) {
+ return null;
+ }
+ const target = new Date(Date.UTC(year, month - 1 + months, 1));
+ const lastDay = new Date(
+ Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0),
+ ).getUTCDate();
+ target.setUTCDate(Math.min(day, lastDay));
+ return target.toISOString().slice(0, 10);
+}
+
+/**
+ * Whether a verification made on `onIso` has passed its window as of `todayIso`.
+ * A date that cannot be read is treated as expired, so a typo never keeps a
+ * stale badge alive.
+ *
+ * @param {string} onIso
+ * @param {number} months
+ * @param {string} todayIso
+ * @returns {boolean}
+ */
+export function isVerificationExpired(onIso, months, todayIso) {
+ const expiry = verificationExpiry(onIso, months);
+ if (expiry === null) return true;
+ // ISO dates sort in chronological order.
+ return todayIso > expiry;
+}
diff --git a/src/app/docs/(article)/awesome-skills-plugins/page.tsx b/src/app/docs/(article)/awesome-skills-plugins/page.tsx
index 3a8e45d..3884260 100644
--- a/src/app/docs/(article)/awesome-skills-plugins/page.tsx
+++ b/src/app/docs/(article)/awesome-skills-plugins/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
-import { fetchAwesomeList } from "@/lib/awesome-list";
+import { fetchAwesomeList, type AwesomeSection } from "@/lib/awesome-list";
+import { AwesomeListUnavailable } from "@/components/docs/awesome-list-unavailable";
import { AwesomeListBrowser } from "@/components/docs/awesome-list-browser";
export const metadata: Metadata = {
@@ -12,7 +13,18 @@ export const metadata: Metadata = {
export const revalidate = 86400;
export default async function AwesomeSkillsPluginsPage() {
- const sections = await fetchAwesomeList("awesome-skills-plugins-for-students");
+ let sections: AwesomeSection[];
+ try {
+ sections = await fetchAwesomeList("awesome-skills-plugins-for-students");
+ } catch {
+ // GitHub rate limits, a renamed repo or an outage shouldn't crash the page.
+ return (
+
+ );
+ }
return (
+ );
+ }
return (
+ );
+ }
return (
({
+ ...(await importOriginal()),
+ fetchAwesomeList: vi.fn(),
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.mocked(fetchAwesomeList).mockReset();
+});
+
+const pages = [
+ [
+ "awesome-skills-plugins",
+ AwesomeSkillsPluginsPage,
+ "awesome-skills-plugins-for-students",
+ ],
+ ["awesome-student-resources", AwesomeStudentResourcesPage, "awesome-student-resources"],
+ ["awesome-study-resources", AwesomeStudyResourcesPage, "awesome-study-resources"],
+] as const;
+
+describe.each(pages)("%s docs page", (_name, Page, repo) => {
+ it("asks for its own list", async () => {
+ vi.mocked(fetchAwesomeList).mockResolvedValue([
+ {
+ title: "Maths",
+ entries: [{ name: "Khan", url: "https://example.com", description: "Learn." }],
+ },
+ ]);
+ render(await Page());
+ expect(fetchAwesomeList).toHaveBeenCalledWith(repo);
+ expect(screen.queryByText("We couldn't load this list right now")).toBeNull();
+ });
+
+ it("shows a message with a link to GitHub when the list can't be fetched, instead of crashing", async () => {
+ vi.mocked(fetchAwesomeList).mockRejectedValue(new Error("Failed to fetch: 403"));
+ render(await Page());
+ expect(screen.getByText("We couldn't load this list right now")).toBeTruthy();
+ const link = screen.getByRole("link", { name: new RegExp(`Open ${repo} on GitHub`) });
+ expect(link.getAttribute("href")).toBe(`https://github.com/StudentSuite/${repo}`);
+ });
+});
diff --git a/src/app/error-pages.test.tsx b/src/app/error-pages.test.tsx
new file mode 100644
index 0000000..4db4f7d
--- /dev/null
+++ b/src/app/error-pages.test.tsx
@@ -0,0 +1,40 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import ErrorPage from "@/app/error";
+import NotFound from "@/app/not-found";
+
+afterEach(cleanup);
+
+describe("not-found page", () => {
+ it("is a themed 404 with a way back", () => {
+ render();
+ expect(screen.getByRole("heading", { name: "Page not found" })).toBeTruthy();
+ expect(
+ screen.getByRole("link", { name: "Back to the map" }).getAttribute("href"),
+ ).toBe("/map");
+ expect(
+ screen.getByRole("link", { name: "Browse competitions" }).getAttribute("href"),
+ ).toBe("/competitions");
+ expect(screen.getByRole("link", { name: "Home" }).getAttribute("href")).toBe("/");
+ });
+});
+
+describe("error page", () => {
+ it("announces the problem and retries on request", () => {
+ const reset = vi.fn();
+ render();
+ expect(screen.getByRole("alert")).toBeTruthy();
+ expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeTruthy();
+ // The message of the error is not shown to visitors.
+ expect(screen.queryByText("boom")).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(reset).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows the digest as a reference when there is one", () => {
+ const error = Object.assign(new Error("boom"), { digest: "abc123" });
+ render();
+ expect(screen.getByText("Error reference: abc123")).toBeTruthy();
+ });
+});
diff --git a/src/app/error.tsx b/src/app/error.tsx
new file mode 100644
index 0000000..5e8b17a
--- /dev/null
+++ b/src/app/error.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import Link from "next/link";
+import { TriangleAlert } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+
+interface ErrorPageProps {
+ error: Error & { digest?: string };
+ reset: () => void;
+}
+
+/** The site's own error screen, used when rendering a route throws. `reset` re-renders the route segment. */
+export default function ErrorPage({ error, reset }: ErrorPageProps) {
+ return (
+
+
+
+ Something went wrong
+
+
+ This page hit an unexpected error. Trying again often fixes it.
+
+ {error.digest ? (
+
+ Error reference: {error.digest}
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..3bf3f1f
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,37 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { MapPinOff } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+
+export const metadata: Metadata = {
+ title: "Page not found",
+ robots: { index: false },
+};
+
+/** The site's own 404, used for unknown routes and for `notFound()` calls such as an unknown city or competition. */
+export default function NotFound() {
+ return (
+
+
+
+ Page not found
+
+
+ We couldn't find that page. The link may be old, or the place or competition
+ may have been renamed or removed.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/docs/awesome-list-unavailable.tsx b/src/components/docs/awesome-list-unavailable.tsx
new file mode 100644
index 0000000..5347e25
--- /dev/null
+++ b/src/components/docs/awesome-list-unavailable.tsx
@@ -0,0 +1,39 @@
+import { ExternalLink } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+
+interface AwesomeListUnavailableProps {
+ repoName: string;
+ repoUrl: string;
+}
+
+/**
+ * Shown on an awesome-list docs page when the list can't be fetched from
+ * GitHub (rate limit, renamed repo, outage). The list itself always stays
+ * readable on GitHub, so the page points there instead of crashing.
+ */
+export function AwesomeListUnavailable({
+ repoName,
+ repoUrl,
+}: AwesomeListUnavailableProps) {
+ return (
+
+
+ We couldn't load this list right now
+
+
+ {repoName} is synced from GitHub, and GitHub didn't answer. Try again in a
+ little while, or read the list on GitHub in the meantime.
+
+
+
+ );
+}
diff --git a/src/components/map/results-list.test.tsx b/src/components/map/results-list.test.tsx
index e408ca7..105eb0e 100644
--- a/src/components/map/results-list.test.tsx
+++ b/src/components/map/results-list.test.tsx
@@ -1,9 +1,18 @@
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ResultsList } from "@/components/map/results-list";
-afterEach(cleanup);
+beforeEach(() => {
+ // The verified badge expires with time, so these tests run on a fixed date.
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-10-21T12:00:00Z"));
+});
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+});
describe("ResultsList empty state", () => {
it("describes active filters and offers reset and contribution actions", () => {
diff --git a/src/components/pins/verified-badge.test.tsx b/src/components/pins/verified-badge.test.tsx
new file mode 100644
index 0000000..32f50d6
--- /dev/null
+++ b/src/components/pins/verified-badge.test.tsx
@@ -0,0 +1,66 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { VerifiedBadge } from "@/components/pins/verified-badge";
+import type { Place } from "@/lib/types";
+
+afterEach(cleanup);
+
+const base: Place = {
+ id: "mum-library-07",
+ name: "City Library",
+ type: "library",
+ city: "mumbai",
+ lat: 19.0176,
+ lng: 72.8562,
+ gmaps_link: "https://maps.google.com/?q=19.0176,72.8562",
+ added_by: "someone",
+};
+
+const now = new Date("2026-10-21T12:00:00Z");
+
+describe("VerifiedBadge", () => {
+ it("shows the badge for a recent verification", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Verified")).toBeTruthy();
+ expect(screen.getByText("Aug 2026")).toBeTruthy();
+ });
+
+ it("shows nothing once a library's verification is older than 12 months", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("shows nothing once an exam centre's verification is older than 6 months", () => {
+ const place: Place = {
+ ...base,
+ type: "sat_centre",
+ verified: { by: "verifier", on: "2026-03-01" },
+ };
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ cleanup();
+ render(
+ ,
+ );
+ expect(screen.getByText("Verified")).toBeTruthy();
+ });
+
+ it("shows nothing for an unverified place", () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+});
diff --git a/src/components/pins/verified-badge.tsx b/src/components/pins/verified-badge.tsx
index 3b65f09..7ebf7de 100644
--- a/src/components/pins/verified-badge.tsx
+++ b/src/components/pins/verified-badge.tsx
@@ -1,5 +1,6 @@
import { BadgeCheck } from "lucide-react";
+import { hasCurrentVerification } from "@/lib/verification";
import type { Place } from "@/lib/types";
/** Human-readable form of the verification date, e.g. "Jun 2026". */
@@ -11,9 +12,13 @@ export function formatVerifiedOn(iso: string): string {
});
}
-/** Subtle badge for places a contributor re-checked. Absent for unverified places. */
-export function VerifiedBadge({ place }: { place: Place }) {
- if (!place.verified) return null;
+/**
+ * Subtle badge for places a contributor re-checked. Absent for unverified places and
+ * once the verification is older than its window (12 months, or 6 for exam centres), so
+ * an old check never reads as current. `now` is for tests.
+ */
+export function VerifiedBadge({ place, now }: { place: Place; now?: Date }) {
+ if (!place.verified || !hasCurrentVerification(place, now)) return null;
return (
{
expect(result.status).toBe(0);
expect(result.stdout).toContain("1 record(s), 0 dated record(s)");
});
+
+ describe("verification window", () => {
+ // TODAY is 2026-08-22, so 6 months back is 2026-02-22 and 12 months back is 2025-08-22.
+ it("flags an exam centre verified more than 6 months ago", () => {
+ const result = runFreshness([
+ { id: "old-centre", type: "sat_centre", verified: { by: "a", on: "2026-02-21" } },
+ ]);
+
+ expect(result.status).toBe(1);
+ expect(result.stderr).toContain("id: old-centre");
+ expect(result.stderr).toContain(
+ "verified.on 2026-02-21 is past its 6-month window",
+ );
+ });
+
+ it("accepts an exam centre verified exactly 6 months ago", () => {
+ const result = runFreshness([
+ { id: "centre", type: "sat_centre", verified: { by: "a", on: "2026-02-22" } },
+ ]);
+
+ expect(result.status).toBe(0);
+ expect(result.stdout).toContain("1 record(s), 1 dated record(s)");
+ });
+
+ it("gives every other place type 12 months", () => {
+ const library = (on: string) => [
+ { id: "lib", type: "library", verified: { by: "a", on } },
+ ];
+
+ expect(runFreshness(library("2026-02-21"), TODAY, "library.json").status).toBe(0);
+ expect(runFreshness(library("2025-08-22"), TODAY, "library.json").status).toBe(0);
+ const old = runFreshness(library("2025-08-21"), TODAY, "library.json");
+ expect(old.status).toBe(1);
+ expect(old.stderr).toContain("past its 12-month window (expired 2026-08-21)");
+ });
+
+ it("reads the place type from the file name when the record has none", () => {
+ const record = [{ id: "centre", verified: { by: "a", on: "2026-02-21" } }];
+
+ expect(runFreshness(record, TODAY, "sat_centre.json").status).toBe(1);
+ expect(runFreshness(record, TODAY, "library.json").status).toBe(0);
+ });
+
+ it("flags a verified.on that is not a real date", () => {
+ const result = runFreshness([
+ { id: "typo", type: "library", verified: { by: "a", on: "2026-02-30" } },
+ ]);
+
+ expect(result.status).toBe(1);
+ expect(result.stderr).toContain("verified.on must be a real ISO date");
+ });
+
+ it("ignores records with no verified block", () => {
+ expect(runFreshness([{ id: "plain", type: "sat_centre" }]).status).toBe(0);
+ });
+ });
});
diff --git a/src/lib/jsonld.test.ts b/src/lib/jsonld.test.ts
index 0147488..c6a9217 100644
--- a/src/lib/jsonld.test.ts
+++ b/src/lib/jsonld.test.ts
@@ -9,6 +9,7 @@ import {
schemaOrgType,
} from "@/lib/jsonld";
import { site } from "@/lib/site";
+import { COMPETITION_CATEGORIES } from "@/lib/types";
import type { Competition } from "@/lib/types";
import type { Place } from "@/lib/types";
@@ -191,6 +192,51 @@ describe("competitionJsonLd", () => {
);
});
+ it("leaves Event-only properties off a scholarship", () => {
+ const node = competitionJsonLd(
+ competition({
+ category: "scholarship",
+ format: "online",
+ dates: [
+ {
+ label: "Application deadline",
+ date: "2027-03-01",
+ type: "deadline",
+ timezone: "UTC",
+ estimated: false,
+ source_url: "https://example.com/apply",
+ },
+ ],
+ }),
+ );
+ expect(node["@type"]).toBe("EducationalOccupationalProgram");
+ expect(node).not.toHaveProperty("eventAttendanceMode");
+ expect(node).not.toHaveProperty("startDate");
+ expect(node).not.toHaveProperty("endDate");
+ // The properties that do belong to any schema.org Thing are still there.
+ expect(node).toMatchObject({
+ name: "Breakthrough Junior Challenge",
+ isAccessibleForFree: true,
+ });
+ });
+
+ it("keeps eventAttendanceMode, startDate and endDate on every other category", () => {
+ for (const category of ["stem", "coding", "essay_writing", "debate_mun"] as const) {
+ const node = competitionJsonLd(competition({ category }));
+ expect(node["@type"], category).toBe("Event");
+ expect(node, category).toHaveProperty("eventAttendanceMode");
+ expect(node, category).toHaveProperty("startDate");
+ }
+ });
+
+ it("only Event nodes carry Event-only properties, whatever the category", () => {
+ for (const category of COMPETITION_CATEGORIES) {
+ const node = competitionJsonLd(competition({ category }));
+ const isEvent = node["@type"] === "Event";
+ expect("eventAttendanceMode" in node, category).toBe(isEvent);
+ }
+ });
+
it("maps in_person and hybrid formats to their own attendance mode", () => {
expect(
competitionJsonLd(competition({ format: "in_person" })).eventAttendanceMode,
diff --git a/src/lib/jsonld.ts b/src/lib/jsonld.ts
index 443c754..732d016 100644
--- a/src/lib/jsonld.ts
+++ b/src/lib/jsonld.ts
@@ -81,7 +81,9 @@ export function schemaOrgCompetitionType(category: Competition["category"]): str
/**
* Build the schema.org JSON-LD graph node for one competition.
*
- * Only `estimated: false` dates ever reach `startDate`/`endDate` -
+ * Event-only properties (`eventAttendanceMode`, `startDate`, `endDate`) are
+ * written only when the node is an `Event`. Only `estimated: false` dates
+ * ever reach `startDate`/`endDate` -
* publishing an estimated date as structured data would invite a search
* engine to present a guess as a fact. When every date on the record is
* still estimated, `startDate`/`endDate` are omitted entirely rather than
@@ -106,13 +108,18 @@ export function competitionJsonLd(competition: Competition): Record 0) {
- node.startDate = confirmedDates[0];
- node.endDate = confirmedDates[confirmedDates.length - 1];
+ // eventAttendanceMode, startDate and endDate are schema.org Event properties.
+ // An EducationalOccupationalProgram (a scholarship) doesn't have them, so
+ // putting them on it is a type mismatch that structured-data validators flag.
+ if (node["@type"] === "Event") {
+ node.eventAttendanceMode = EVENT_ATTENDANCE_MODES[competition.format];
+ if (confirmedDates.length > 0) {
+ node.startDate = confirmedDates[0];
+ node.endDate = confirmedDates[confirmedDates.length - 1];
+ }
}
return node;
diff --git a/src/lib/verification.test.ts b/src/lib/verification.test.ts
new file mode 100644
index 0000000..e0849c2
--- /dev/null
+++ b/src/lib/verification.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ hasCurrentVerification,
+ verifiedUntil,
+ windowMonthsFor,
+} from "@/lib/verification";
+import {
+ isVerificationExpired,
+ verificationExpiry,
+ verificationWindowMonths,
+} from "../../scripts/lib/verification-window.mjs";
+import type { Competition, Place } from "@/lib/types";
+
+function place(overrides: Partial = {}): Place {
+ return {
+ id: "mum-library-01",
+ name: "City Library",
+ type: "library",
+ city: "mumbai",
+ lat: 19.0176,
+ lng: 72.8562,
+ gmaps_link: "https://maps.google.com/?q=19.0176,72.8562",
+ added_by: "test",
+ ...overrides,
+ };
+}
+
+function competition(overrides: Partial = {}): Competition {
+ return {
+ id: "regeneron-isef",
+ name: "Regeneron ISEF",
+ organizer: "Society for Science",
+ organizer_url: "https://www.societyforscience.org",
+ category: "stem",
+ subjects: [],
+ description: "A science fair.",
+ format: "in_person",
+ age_min: 14,
+ age_max: 18,
+ participation: "individual",
+ region: "international",
+ fee: { amount: 0, currency: "USD" },
+ prize: "Awards",
+ official_url: "https://www.societyforscience.org/isef/",
+ cycle_year: 2027,
+ dates: [],
+ added_by: "test",
+ ...overrides,
+ };
+}
+
+describe("verification window", () => {
+ it("is 6 months for exam centres and competitions and 12 for everything else", () => {
+ expect(verificationWindowMonths({ type: "sat_centre" })).toBe(6);
+ expect(verificationWindowMonths({ type: "foreign_lang_exam_centre" })).toBe(6);
+ expect(verificationWindowMonths({ competition: true })).toBe(6);
+ for (const type of ["library", "other_places", "airport", "gov_offices"]) {
+ expect(verificationWindowMonths({ type })).toBe(12);
+ }
+ expect(windowMonthsFor(place({ type: "sat_centre" }))).toBe(6);
+ expect(windowMonthsFor(place({ type: "library" }))).toBe(12);
+ expect(windowMonthsFor(competition())).toBe(6);
+ });
+});
+
+describe("verificationExpiry", () => {
+ it("is the same day the window later", () => {
+ expect(verificationExpiry("2026-01-15", 12)).toBe("2027-01-15");
+ expect(verificationExpiry("2026-01-15", 6)).toBe("2026-07-15");
+ expect(verificationExpiry("2026-08-22", 6)).toBe("2027-02-22");
+ });
+
+ it("clamps to the end of a shorter month", () => {
+ expect(verificationExpiry("2026-08-31", 6)).toBe("2027-02-28");
+ expect(verificationExpiry("2023-08-31", 6)).toBe("2024-02-29");
+ expect(verificationExpiry("2024-02-29", 12)).toBe("2025-02-28");
+ });
+
+ it("returns null for something that is not a real date", () => {
+ expect(verificationExpiry("2026-02-30", 6)).toBeNull();
+ expect(verificationExpiry("last week", 6)).toBeNull();
+ expect(verificationExpiry("", 6)).toBeNull();
+ });
+});
+
+describe("isVerificationExpired", () => {
+ it("is still valid on the expiry day and expired the day after", () => {
+ expect(isVerificationExpired("2026-01-15", 12, "2027-01-15")).toBe(false);
+ expect(isVerificationExpired("2026-01-15", 12, "2027-01-16")).toBe(true);
+ expect(isVerificationExpired("2026-01-15", 6, "2026-07-15")).toBe(false);
+ expect(isVerificationExpired("2026-01-15", 6, "2026-07-16")).toBe(true);
+ });
+
+ it("treats an unreadable date as expired", () => {
+ expect(isVerificationExpired("not-a-date", 12, "2026-01-01")).toBe(true);
+ });
+});
+
+describe("hasCurrentVerification", () => {
+ const verified = (on: string) => ({ by: "someone", on });
+ const now = new Date("2026-10-21T12:00:00Z");
+
+ it("is false for a record with no verified block", () => {
+ expect(hasCurrentVerification(place(), now)).toBe(false);
+ expect(hasCurrentVerification(competition(), now)).toBe(false);
+ });
+
+ it("keeps a library verified for 12 months", () => {
+ expect(hasCurrentVerification(place({ verified: verified("2025-10-21") }), now)).toBe(
+ true,
+ );
+ expect(hasCurrentVerification(place({ verified: verified("2025-10-20") }), now)).toBe(
+ false,
+ );
+ });
+
+ it("expires an exam centre after 6 months, not 12", () => {
+ const centre = (on: string) => place({ type: "sat_centre", verified: verified(on) });
+ expect(hasCurrentVerification(centre("2026-04-21"), now)).toBe(true);
+ expect(hasCurrentVerification(centre("2026-04-20"), now)).toBe(false);
+ // The same date is still fine for a library.
+ expect(hasCurrentVerification(place({ verified: verified("2026-04-20") }), now)).toBe(
+ true,
+ );
+ });
+
+ it("expires a competition after 6 months", () => {
+ expect(
+ hasCurrentVerification(competition({ verified: verified("2026-04-21") }), now),
+ ).toBe(true);
+ expect(
+ hasCurrentVerification(competition({ verified: verified("2026-04-20") }), now),
+ ).toBe(false);
+ });
+
+ it("reports when the verification runs out", () => {
+ expect(verifiedUntil(place({ verified: verified("2026-01-15") }))).toBe("2027-01-15");
+ expect(
+ verifiedUntil(place({ type: "sat_centre", verified: verified("2026-01-15") })),
+ ).toBe("2026-07-15");
+ expect(verifiedUntil(place())).toBeNull();
+ });
+});
diff --git a/src/lib/verification.ts b/src/lib/verification.ts
new file mode 100644
index 0000000..4d443ae
--- /dev/null
+++ b/src/lib/verification.ts
@@ -0,0 +1,38 @@
+import {
+ isVerificationExpired,
+ verificationExpiry,
+ verificationWindowMonths,
+} from "../../scripts/lib/verification-window.mjs";
+import type { Competition, Place } from "@/lib/types";
+
+/** Today's date as YYYY-MM-DD in UTC, the same form the data's `verified.on` uses. */
+function isoDay(now: Date): string {
+ return now.toISOString().slice(0, 10);
+}
+
+/** Months a place's or competition's `verified` block stays valid (6 for exam centres and competitions, 12 otherwise). */
+export function windowMonthsFor(record: Place | Competition): number {
+ return "type" in record
+ ? verificationWindowMonths({ type: record.type })
+ : verificationWindowMonths({ competition: true });
+}
+
+/** The last day the record's verification is still valid, or null when it has none or the date is invalid. */
+export function verifiedUntil(record: Place | Competition): string | null {
+ return record.verified
+ ? verificationExpiry(record.verified.on, windowMonthsFor(record))
+ : null;
+}
+
+/**
+ * Whether the record has a verification that is still inside its window.
+ * An old verification no longer counts, so the badge disappears until the
+ * place is checked again (see data/CONTRIBUTING.md).
+ */
+export function hasCurrentVerification(
+ record: Place | Competition,
+ now: Date = new Date(),
+): boolean {
+ if (!record.verified) return false;
+ return !isVerificationExpired(record.verified.on, windowMonthsFor(record), isoDay(now));
+}