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
19 changes: 18 additions & 1 deletion frontend/src/pages/lectures/Lectures.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -397,6 +403,17 @@ export default function Lectures() {
</Link>
.
</Typography>
<Button
component="a"
href={GOOGLE_CALENDAR_SUBSCRIBE_URL}
target="_blank"
rel="noopener noreferrer"
variant="outlined"
size="small"
sx={{ marginBottom: "16px", textTransform: "none" }}
>
Subscribe in Google Calendar
</Button>
{loading ? (
<Loading />
) : error ? (
Expand Down
137 changes: 136 additions & 1 deletion kernelboard/api/events.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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():
Expand Down Expand Up @@ -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)}",
)
99 changes: 99 additions & 0 deletions tests/api/test_events_api.py
Original file line number Diff line number Diff line change
@@ -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"))
Loading