diff --git a/src/lib/tess.ts b/src/lib/tess.ts
new file mode 100644
index 00000000..81df9606
--- /dev/null
+++ b/src/lib/tess.ts
@@ -0,0 +1,142 @@
+const API = "https://tess.elixir-europe.org";
+
+const ATTEMPTS: number = 5;
+const TIMEOUT_MS = 20_000;
+
+export interface TessEvent {
+ id: number;
+ title: string;
+ url: string;
+ slug?: string;
+ description?: string;
+ start?: string;
+ end?: string;
+ venue?: string;
+ city?: string;
+ country?: string;
+ organizer?: string;
+}
+
+export interface TessMaterial {
+ id: number;
+ title: string;
+ url: string;
+ description?: string;
+ doi?: string;
+}
+
+type Params = Record;
+
+/**
+ * `reachable` separates "TESS has nothing to list" from "TESS never answered".
+ * Both leave `items` empty, and rendering them the same way is what let the old
+ * widget show a plausible-looking empty state over a dead feed for months.
+ */
+export interface TessResult {
+ items: T[];
+ reachable: boolean;
+}
+
+function query(params: Params): string {
+ const q = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ // TESS expects repeated bracketed keys for arrays: country[]=Norway
+ if (Array.isArray(value)) value.forEach(v => q.append(`${key}[]`, v));
+ else q.append(key, String(value));
+ }
+ return q.toString();
+}
+
+// Without an explicit JSON Accept header TESS serves an HTML 403 instead of the
+// API response, which is what broke the browser widget this replaced: the error
+// page carries no CORS headers, so the failure surfaced as a CORS violation.
+// TESS also returns 5xx intermittently, hence the retries.
+async function fetchList(path: string, params: Params): Promise> {
+ const url = `${API}/${path}?${query(params)}`;
+
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
+ try {
+ const res = await fetch(url, {
+ headers: { Accept: "application/json" },
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+
+ const body = await res.json();
+ if (!Array.isArray(body)) throw new Error("expected a JSON array");
+ return { items: body as T[], reachable: true };
+ } catch (err) {
+ const reason = err instanceof Error ? err.message : String(err);
+ if (attempt === ATTEMPTS) {
+ const tries = ATTEMPTS === 1 ? "1 attempt" : `${ATTEMPTS} attempts`;
+ console.warn(`[tess] ${path} failed after ${tries} (${reason}); the section will say so`);
+ return { items: [], reachable: false };
+ }
+ await new Promise(r => setTimeout(r, attempt * 1500));
+ }
+ }
+ return { items: [], reachable: false };
+}
+
+export const upcomingEvents = (pageSize = 5) =>
+ fetchList("events", { page_size: pageSize, country: ["Norway"] });
+
+export const pastEvents = (pageSize = 10) =>
+ fetchList("events", {
+ page_size: pageSize,
+ sort: "new",
+ country: ["Norway"],
+ include_expired: true,
+ include_disabled: false,
+ });
+
+export const materials = (pageSize = 10) =>
+ fetchList("materials", { page_size: pageSize, node: ["Norway"] });
+
+// These URLs come from a feed we do not control and get baked into static HTML,
+// so a bad one would stay on the page until the next build. Anything that is not
+// http(s), a `javascript:` URL being the case that matters, is dropped for a
+// TESS address we build ourselves.
+function safeUrl(raw: string | undefined, fallback: string): string {
+ if (!raw) return fallback;
+ try {
+ const { protocol } = new URL(raw);
+ return protocol === "http:" || protocol === "https:" ? raw : fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+/** Link to the event's own registration page, falling back to its TESS entry. */
+export function eventLink(event: TessEvent): string {
+ const onTess = event.slug
+ ? `${API}/events/${encodeURIComponent(event.slug)}`
+ : `${API}/events`;
+ return safeUrl(event.url, onTess);
+}
+
+/** Link to the material on TESS, falling back to the Norwegian listing. */
+export function materialLink(material: TessMaterial): string {
+ return safeUrl(material.url, `${API}/materials?node[]=Norway`);
+}
+
+/** "12 Mar 2026", or a range when the event spans more than one day. */
+export function eventDates(event: TessEvent): string {
+ const fmt = (iso: string) =>
+ new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
+
+ if (!event.start) return "";
+ const start = fmt(event.start);
+ if (!event.end) return start;
+
+ const end = fmt(event.end);
+ return start === end ? start : `${start} to ${end}`;
+}
+
+// Venue is left out on purpose. TESS stores a full street address there and
+// often a doubled one ("Moltke Moes vei, Moltke Moes vei"), which is noise in a
+// one-line summary; the linked event page carries the address.
+/** "Oslo, Norway", skipping whichever part TESS left blank. */
+export function eventPlace(event: TessEvent): string {
+ return [event.city, event.country].filter(Boolean).join(", ");
+}
diff --git a/src/pages/training/index.astro b/src/pages/training/index.astro
index d165df01..10af68c7 100644
--- a/src/pages/training/index.astro
+++ b/src/pages/training/index.astro
@@ -10,8 +10,17 @@ import {
MagnifyingGlassIcon,
WrenchScrewdriverIcon,
} from "@heroicons/react/24/outline";
+import Unavailable from "../../components/tess-unavailable.astro";
+import { upcomingEvents, pastEvents, materials, eventLink, materialLink, eventDates, eventPlace } from "../../lib/tess";
-const BASE = import.meta.env.BASE_URL.replace(/\/$/, '');
+// Fetched at build time rather than from the browser: TESS rejects a request
+// that does not ask for JSON, and the error page it returns carries no CORS
+// headers. See src/lib/tess.ts.
+const [upcoming, past, trainingMaterials] = await Promise.all([
+ upcomingEvents(5),
+ pastEvents(10),
+ materials(10),
+]);
---
-
-
-
Training
@@ -137,216 +143,137 @@ const BASE = import.meta.env.BASE_URL.replace(/\/$/, '');