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)); +}