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
28 changes: 28 additions & 0 deletions scripts/check-data-freshness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
71 changes: 71 additions & 0 deletions scripts/lib/verification-window.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 14 additions & 2 deletions src/app/docs/(article)/awesome-skills-plugins/page.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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 (
<AwesomeListUnavailable
repoName="awesome-skills-plugins-for-students"
repoUrl="https://github.com/StudentSuite/awesome-skills-plugins-for-students"
/>
);
}

return (
<AwesomeListBrowser
Expand Down
16 changes: 14 additions & 2 deletions src/app/docs/(article)/awesome-student-resources/page.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -12,7 +13,18 @@ export const metadata: Metadata = {
export const revalidate = 86400;

export default async function AwesomeStudentResourcesPage() {
const sections = await fetchAwesomeList("awesome-student-resources");
let sections: AwesomeSection[];
try {
sections = await fetchAwesomeList("awesome-student-resources");
} catch {
// GitHub rate limits, a renamed repo or an outage shouldn't crash the page.
return (
<AwesomeListUnavailable
repoName="awesome-student-resources"
repoUrl="https://github.com/StudentSuite/awesome-student-resources"
/>
);
}

return (
<AwesomeListBrowser
Expand Down
16 changes: 14 additions & 2 deletions src/app/docs/(article)/awesome-study-resources/page.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -12,7 +13,18 @@ export const metadata: Metadata = {
export const revalidate = 86400;

export default async function AwesomeStudyResourcesPage() {
const sections = await fetchAwesomeList("awesome-study-resources");
let sections: AwesomeSection[];
try {
sections = await fetchAwesomeList("awesome-study-resources");
} catch {
// GitHub rate limits, a renamed repo or an outage shouldn't crash the page.
return (
<AwesomeListUnavailable
repoName="awesome-study-resources"
repoUrl="https://github.com/StudentSuite/awesome-study-resources"
/>
);
}

return (
<AwesomeListBrowser
Expand Down
49 changes: 49 additions & 0 deletions src/app/docs/awesome-pages.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import AwesomeSkillsPluginsPage from "@/app/docs/(article)/awesome-skills-plugins/page";
import AwesomeStudentResourcesPage from "@/app/docs/(article)/awesome-student-resources/page";
import AwesomeStudyResourcesPage from "@/app/docs/(article)/awesome-study-resources/page";
import { fetchAwesomeList } from "@/lib/awesome-list";

vi.mock("@/lib/awesome-list", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/awesome-list")>()),
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}`);
});
});
40 changes: 40 additions & 0 deletions src/app/error-pages.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<NotFound />);
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(<ErrorPage error={new Error("boom")} reset={reset} />);
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(<ErrorPage error={error} reset={vi.fn()} />);
expect(screen.getByText("Error reference: abc123")).toBeTruthy();
});
});
40 changes: 40 additions & 0 deletions src/app/error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="alert"
className="mx-auto flex w-full max-w-md flex-col items-center px-4 py-20 text-center"
>
<TriangleAlert className="size-10 text-muted-foreground" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold tracking-tight">
Something went wrong
</h1>
<p className="mt-2 text-muted-foreground">
This page hit an unexpected error. Trying again often fixes it.
</p>
{error.digest ? (
<p className="mt-2 font-mono text-xs text-muted-foreground">
Error reference: {error.digest}
</p>
) : null}
<div className="mt-6 flex flex-wrap justify-center gap-3">
<Button onClick={() => reset()}>Try again</Button>
<Button asChild variant="outline">
<Link href="/">Home</Link>
</Button>
</div>
</div>
);
}
37 changes: 37 additions & 0 deletions src/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto flex w-full max-w-md flex-col items-center px-4 py-20 text-center">
<MapPinOff className="size-10 text-muted-foreground" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold tracking-tight">
Page not found
</h1>
<p className="mt-2 text-muted-foreground">
We couldn&apos;t find that page. The link may be old, or the place or competition
may have been renamed or removed.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<Button asChild>
<Link href="/map">Back to the map</Link>
</Button>
<Button asChild variant="outline">
<Link href="/competitions">Browse competitions</Link>
</Button>
<Button asChild variant="outline">
<Link href="/">Home</Link>
</Button>
</div>
</div>
);
}
Loading
Loading