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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion .github/workflows/deploy-cloudflare-workers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ jobs:

deploy:
name: Deploy Cloudflare Worker
if: github.repository == 'devcongress/website' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
if: >-
github.repository == 'devcongress/website' &&
github.ref == 'refs/heads/main' &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
needs: validate-and-build
runs-on: ubuntu-latest
environment:
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ credentials or connecting the browser directly to the operational system.
Pushes to `main`, manual workflow runs, and the daily `06:17 UTC` scheduled
build refresh the static event snapshot.

## Event calendar subscription

`/events/calendar.ics` is a public iCalendar subscription generated from the
same validated Events Management feed as `/events/`. It includes only published
events that are still in progress or have not started at build time; past events
remain available on the website but are not carried into subscribers' calendars.

The Events page links the feed to Google Calendar. The feed is refreshed by the
same push, manual, and daily builds described above. No organizer credentials or
unmoderated submissions are included.

## Public event submission launch controls

Event submissions use one fail-closed build-time flag. Add it as a GitHub
Expand Down
4 changes: 2 additions & 2 deletions src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -189,11 +189,11 @@ const site = siteEntries[0]!.data;
cursor: pointer;
}

.btn:hover {
.btn:not(:disabled):hover {
transform: translate(-2px, -2px);
box-shadow: var(--shadow-solid);
}
.btn:active {
.btn:not(:disabled):active {
transform: translate(0, 0);
box-shadow: none;
}
Expand Down
114 changes: 114 additions & 0 deletions src/lib/event-calendar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { WebsiteEvent } from "./events";

const CALENDAR_NAME = "DevCongress Events";
const CALENDAR_DESCRIPTION =
"Published DevCongress meetups, workshops, hackathons, webinars, and conferences.";
const CALENDAR_ORIGIN = "https://devcongress.org";

export function upcomingCalendarEvents(
events: WebsiteEvent[],
now = new Date(),
): WebsiteEvent[] {
const nowTime = now.getTime();
return [...events]
.filter((event) => new Date(event.endsAt).getTime() >= nowTime)
.sort(
(left, right) =>
new Date(left.startsAt).getTime() - new Date(right.startsAt).getTime(),
);
}

export function createEventsCalendar(
events: WebsiteEvent[],
now = new Date(),
): string {
const calendarLines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//DevCongress//Events Calendar//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
`X-WR-CALNAME:${escapeIcsText(CALENDAR_NAME)}`,
`X-WR-CALDESC:${escapeIcsText(CALENDAR_DESCRIPTION)}`,
"REFRESH-INTERVAL;VALUE=DURATION:P1D",
"X-PUBLISHED-TTL:P1D",
];

for (const event of upcomingCalendarEvents(events, now)) {
const eventUrl = resolveEventUrl(event);
const description = [event.summary, eventUrl ? `More details: ${eventUrl}` : null]
.filter(Boolean)
.join("\n\n");

calendarLines.push(
"BEGIN:VEVENT",
`UID:${escapeIcsText(`${event.id}@events.devcongress.org`)}`,
`DTSTAMP:${formatIcsDate(event.updatedAt)}`,
`DTSTART:${formatIcsDate(event.startsAt)}`,
`DTEND:${formatIcsDate(event.endsAt)}`,
`SUMMARY:${escapeIcsText(event.title)}`,
`DESCRIPTION:${escapeIcsText(description)}`,
`LOCATION:${escapeIcsText(eventLocation(event))}`,
...(eventUrl ? [`URL:${eventUrl}`] : []),
"STATUS:CONFIRMED",
"END:VEVENT",
);
}

calendarLines.push("END:VCALENDAR");
return calendarLines.map(foldIcsLine).join("\r\n") + "\r\n";
}

function resolveEventUrl(event: WebsiteEvent): string | null {
if (event.detailsUrl) return new URL(event.detailsUrl, CALENDAR_ORIGIN).toString();
return event.registrationUrl ?? event.onlineUrl;
}

function eventLocation(event: WebsiteEvent): string {
if (event.locationType === "online") return "Online";
const physicalLocation = [event.venueName, event.venueAddress]
.filter(Boolean)
.join(", ");
if (event.locationType === "hybrid") {
return physicalLocation ? `${physicalLocation} + online` : "Hybrid";
}
return physicalLocation || "In person";
}

function formatIcsDate(value: string): string {
const date = new Date(value);
if (!Number.isFinite(date.getTime())) throw new Error("Invalid calendar date");
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
}

function escapeIcsText(value: string): string {
return value
.replaceAll("\\", "\\\\")
.replaceAll("\r\n", "\\n")
.replaceAll("\n", "\\n")
.replaceAll(",", "\\,")
.replaceAll(";", "\\;");
}

function foldIcsLine(line: string): string {
const encoder = new TextEncoder();
const chunks: string[] = [];
let chunk = "";
let byteLength = 0;

for (const character of line) {
const characterBytes = encoder.encode(character).byteLength;
const limit = chunks.length === 0 ? 75 : 74;
if (byteLength + characterBytes > limit && chunk) {
chunks.push(chunk);
chunk = character;
byteLength = characterBytes;
} else {
chunk += character;
byteLength += characterBytes;
}
}
if (chunk || chunks.length === 0) chunks.push(chunk);

return chunks.join("\r\n ");
}
3 changes: 3 additions & 0 deletions src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export interface WebsiteEvent {
organizerWebsite: string | null;
coverUrl: string | null;
detailsUrl: string | null;
updatedAt: string;
}

let eventsPromise: Promise<WebsiteEvent[]> | undefined;
Expand Down Expand Up @@ -243,6 +244,7 @@ async function fetchMeetupFallback(): Promise<WebsiteEvent[]> {
organizerWebsite: "https://devcongress.org",
coverUrl: meetup.data.cover,
detailsUrl: `/meetups/${meetup.id}/`,
updatedAt: meetup.data.start,
}));
}

Expand All @@ -268,6 +270,7 @@ function mapPublicEvent(event: PublicEvent): WebsiteEvent {
coverUrl: resolveOptionalWebsiteUrl(event.cover_url),
detailsUrl:
event.ownership === "devcongress" ? `/meetups/${event.slug}/` : null,
updatedAt: event.updated_at,
};
}

Expand Down
17 changes: 17 additions & 0 deletions src/pages/events/calendar.ics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { APIRoute } from "astro";
import { createEventsCalendar } from "../../lib/event-calendar";
import { getEvents } from "../../lib/events";

export const prerender = true;

export const GET: APIRoute = async () => {
const calendar = createEventsCalendar(await getEvents());

return new Response(calendar, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'inline; filename="devcongress-events.ics"',
"Cache-Control": "public, max-age=3600",
},
});
};
Loading
Loading