diff --git a/frontend/src/pages/lectures/Lectures.tsx b/frontend/src/pages/lectures/Lectures.tsx
index aa1b21b..a5c8606 100644
--- a/frontend/src/pages/lectures/Lectures.tsx
+++ b/frontend/src/pages/lectures/Lectures.tsx
@@ -1,10 +1,16 @@
-import { Box, Typography, Link, Chip } from "@mui/material";
+import { Box, Typography, Link, Chip, Button } from "@mui/material";
import type { Theme } from "@mui/material/styles";
import { useEffect, useState } from "react";
import { fetchEvents } from "../../api/api";
import type { DiscordEvent } from "../../api/api";
import Loading from "../../components/common/loading";
+const LECTURE_CALENDAR_FEED_URL =
+ "https://www.gpumode.com/api/events/calendar.ics";
+const GOOGLE_CALENDAR_SUBSCRIBE_URL = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(
+ LECTURE_CALENDAR_FEED_URL,
+)}`;
+
const styles = {
container: {
maxWidth: "900px",
@@ -397,6 +403,17 @@ export default function Lectures() {
.
+
{loading ? (
) : error ? (
diff --git a/kernelboard/api/events.py b/kernelboard/api/events.py
index 02f63a2..2642952 100644
--- a/kernelboard/api/events.py
+++ b/kernelboard/api/events.py
@@ -1,10 +1,11 @@
import logging
import os
import time
+from datetime import datetime, timedelta, timezone
from http import HTTPStatus
import requests
-from flask import Blueprint
+from flask import Blueprint, Response
from kernelboard.lib.status_code import http_error, http_success
@@ -18,6 +19,119 @@
"timestamp": 0,
}
CACHE_TTL_SECONDS = 300 # 5 minutes
+CALENDAR_NAME = "GPU MODE Upcoming Lectures"
+CALENDAR_FILENAME = "gpu-mode-upcoming-lectures.ics"
+
+
+def _parse_datetime(value):
+ """Parse an ISO 8601 value and normalize it to UTC."""
+ if not isinstance(value, str) or not value:
+ return None
+
+ try:
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+
+ return parsed.astimezone(timezone.utc)
+
+
+def _escape_ical_text(value):
+ """Escape a value used by an iCalendar TEXT property."""
+ normalized = str(value or "").replace("\r\n", "\n").replace("\r", "\n")
+ return (
+ normalized.replace("\\", "\\\\")
+ .replace("\n", "\\n")
+ .replace(";", "\\;")
+ .replace(",", "\\,")
+ )
+
+
+def _fold_ical_line(line):
+ """Fold an iCalendar content line at 75 UTF-8 octets."""
+ remaining = line.encode("utf-8")
+ folded = []
+ first_line = True
+
+ while remaining:
+ limit = 75 if first_line else 74
+ split_at = min(limit, len(remaining))
+
+ # Do not split in the middle of a multi-byte UTF-8 character.
+ while split_at < len(remaining) and remaining[split_at] & 0xC0 == 0x80:
+ split_at -= 1
+
+ chunk = remaining[:split_at].decode("utf-8")
+ folded.append(chunk if first_line else f" {chunk}")
+ remaining = remaining[split_at:]
+ first_line = False
+
+ return "\r\n".join(folded)
+
+
+def _render_icalendar(events, now=None):
+ """Render upcoming Discord events as an RFC 5545 calendar feed."""
+ now = now or datetime.now(timezone.utc)
+ if now.tzinfo is None:
+ now = now.replace(tzinfo=timezone.utc)
+ now = now.astimezone(timezone.utc)
+ timestamp = now.strftime("%Y%m%dT%H%M%SZ")
+
+ lines = [
+ "BEGIN:VCALENDAR",
+ "VERSION:2.0",
+ "PRODID:-//GPU MODE//Upcoming Lectures//EN",
+ "CALSCALE:GREGORIAN",
+ "METHOD:PUBLISH",
+ f"X-WR-CALNAME:{_escape_ical_text(CALENDAR_NAME)}",
+ "REFRESH-INTERVAL;VALUE=DURATION:PT5M",
+ "X-PUBLISHED-TTL:PT5M",
+ ]
+
+ for event in events:
+ event_id = event.get("id")
+ start = _parse_datetime(event.get("scheduled_start_time"))
+ scheduled_end = _parse_datetime(event.get("scheduled_end_time"))
+
+ if not event_id or start is None:
+ continue
+
+ # Match the frontend: an event remains upcoming until its end, when set.
+ if (scheduled_end or start) < now:
+ continue
+
+ end = scheduled_end
+ if end is None or end <= start:
+ end = start + timedelta(hours=1)
+
+ event_url = str(event.get("event_url") or "").replace("\r", "").replace("\n", "")
+ description = str(event.get("description") or "").strip()
+ if event_url:
+ description = "\n\n".join(
+ part for part in (description, f"View on Discord: {event_url}") if part
+ )
+
+ lines.extend([
+ "BEGIN:VEVENT",
+ f"UID:discord-{_escape_ical_text(event_id)}@gpumode.com",
+ f"DTSTAMP:{timestamp}",
+ f"DTSTART:{start.strftime('%Y%m%dT%H%M%SZ')}",
+ f"DTEND:{end.strftime('%Y%m%dT%H%M%SZ')}",
+ f"SUMMARY:{_escape_ical_text(event.get('name') or 'GPU MODE Lecture')}",
+ f"DESCRIPTION:{_escape_ical_text(description)}",
+ "LOCATION:GPU MODE Discord",
+ "STATUS:CONFIRMED",
+ "TRANSP:TRANSPARENT",
+ ])
+ if event_url:
+ lines.append(f"URL:{event_url}")
+ lines.append("END:VEVENT")
+
+ lines.append("END:VCALENDAR")
+ return "\r\n".join(_fold_ical_line(line) for line in lines) + "\r\n"
def _get_discord_events():
@@ -89,3 +203,24 @@ def list_events():
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
message=f"Internal server error: {str(e)}",
)
+
+
+@events_bp.route("/calendar.ics", methods=["GET"])
+def calendar_feed():
+ """Return upcoming Discord scheduled events as an iCalendar feed."""
+ try:
+ calendar = _render_icalendar(_get_discord_events())
+ return Response(
+ calendar,
+ content_type="text/calendar; charset=utf-8",
+ headers={
+ "Cache-Control": f"public, max-age={CACHE_TTL_SECONDS}",
+ "Content-Disposition": f'inline; filename="{CALENDAR_FILENAME}"',
+ },
+ )
+ except Exception as e:
+ logger.error(f"Error rendering calendar feed: {e}")
+ return http_error(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+ message=f"Internal server error: {str(e)}",
+ )
diff --git a/tests/api/test_events_api.py b/tests/api/test_events_api.py
new file mode 100644
index 0000000..2d007ac
--- /dev/null
+++ b/tests/api/test_events_api.py
@@ -0,0 +1,99 @@
+from datetime import datetime, timezone
+from unittest.mock import patch
+
+from flask import Blueprint, Flask
+
+from kernelboard.api.events import _render_icalendar, events_bp
+
+
+def _events_client():
+ app = Flask(__name__)
+ api = Blueprint("events_test_api", __name__, url_prefix="/api")
+ api.register_blueprint(events_bp)
+ app.register_blueprint(api)
+ return app.test_client()
+
+
+def test_calendar_feed_returns_upcoming_events():
+ events = [
+ {
+ "id": "123456",
+ "name": "Fast kernels, from A;B",
+ "description": "Line one\nLine two",
+ "scheduled_start_time": "2099-07-27T18:00:00+00:00",
+ "scheduled_end_time": "2099-07-27T19:20:00+00:00",
+ "event_url": "https://discord.com/events/1/123456",
+ }
+ ]
+
+ with patch("kernelboard.api.events._get_discord_events", return_value=events):
+ response = _events_client().get("/api/events/calendar.ics")
+
+ calendar = response.get_data(as_text=True)
+ assert response.status_code == 200
+ assert response.content_type == "text/calendar; charset=utf-8"
+ assert response.headers["Content-Disposition"] == (
+ 'inline; filename="gpu-mode-upcoming-lectures.ics"'
+ )
+ assert "UID:discord-123456@gpumode.com\r\n" in calendar
+ assert "DTSTART:20990727T180000Z\r\n" in calendar
+ assert "DTEND:20990727T192000Z\r\n" in calendar
+ assert "SUMMARY:Fast kernels\\, from A\\;B\r\n" in calendar
+ assert "DESCRIPTION:Line one\\nLine two\\n\\nView on Discord: https://discord.com" in calendar
+ assert "TRANSP:TRANSPARENT\r\n" in calendar
+
+
+def test_calendar_renderer_filters_past_and_defaults_missing_end_time():
+ events = [
+ {
+ "id": "past",
+ "name": "Past lecture",
+ "scheduled_start_time": "2026-07-20T18:00:00+00:00",
+ "scheduled_end_time": "2026-07-20T19:00:00+00:00",
+ },
+ {
+ "id": "current",
+ "name": "Current lecture",
+ "scheduled_start_time": "2026-07-22T18:00:00+00:00",
+ "scheduled_end_time": "2026-07-22T19:00:00+00:00",
+ },
+ {
+ "id": "future",
+ "name": "Future lecture",
+ "scheduled_start_time": "2026-07-23T18:00:00Z",
+ "scheduled_end_time": None,
+ },
+ {
+ "id": "invalid",
+ "name": "Invalid lecture",
+ "scheduled_start_time": "not-a-date",
+ },
+ ]
+
+ calendar = _render_icalendar(
+ events,
+ now=datetime(2026, 7, 22, 18, 30, tzinfo=timezone.utc),
+ )
+
+ assert "Past lecture" not in calendar
+ assert "Invalid lecture" not in calendar
+ assert "SUMMARY:Current lecture\r\n" in calendar
+ assert "SUMMARY:Future lecture\r\n" in calendar
+ assert "DTSTART:20260723T180000Z\r\n" in calendar
+ assert "DTEND:20260723T190000Z\r\n" in calendar
+
+
+def test_calendar_renderer_folds_long_utf8_lines_to_75_octets():
+ calendar = _render_icalendar(
+ [
+ {
+ "id": "long-title",
+ "name": "GPU kernels 🚀 " * 20,
+ "scheduled_start_time": "2099-01-01T10:00:00Z",
+ "scheduled_end_time": "2099-01-01T11:00:00Z",
+ }
+ ],
+ now=datetime(2026, 7, 22, tzinfo=timezone.utc),
+ )
+
+ assert all(len(line.encode("utf-8")) <= 75 for line in calendar.split("\r\n"))