diff --git a/.github/pr-assets/upcoming-events-calendar/desktop-events-calendar.png b/.github/pr-assets/upcoming-events-calendar/desktop-events-calendar.png new file mode 100644 index 0000000..95950c9 Binary files /dev/null and b/.github/pr-assets/upcoming-events-calendar/desktop-events-calendar.png differ diff --git a/.github/pr-assets/upcoming-events-calendar/desktop-google-calendar.png b/.github/pr-assets/upcoming-events-calendar/desktop-google-calendar.png new file mode 100644 index 0000000..009da5a Binary files /dev/null and b/.github/pr-assets/upcoming-events-calendar/desktop-google-calendar.png differ diff --git a/.github/pr-assets/upcoming-events-calendar/mobile-google-calendar-and-events.png b/.github/pr-assets/upcoming-events-calendar/mobile-google-calendar-and-events.png new file mode 100644 index 0000000..d58d6ce Binary files /dev/null and b/.github/pr-assets/upcoming-events-calendar/mobile-google-calendar-and-events.png differ diff --git a/.github/pr-assets/upcoming-events-calendar/no-upcoming-events.png b/.github/pr-assets/upcoming-events-calendar/no-upcoming-events.png new file mode 100644 index 0000000..66c012b Binary files /dev/null and b/.github/pr-assets/upcoming-events-calendar/no-upcoming-events.png differ diff --git a/.github/workflows/deploy-cloudflare-workers.yml b/.github/workflows/deploy-cloudflare-workers.yml index 737c379..2716270 100644 --- a/.github/workflows/deploy-cloudflare-workers.yml +++ b/.github/workflows/deploy-cloudflare-workers.yml @@ -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: diff --git a/README.md b/README.md index be3587d..783b1e8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 85072ea..e480903 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -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; } diff --git a/src/lib/event-calendar.ts b/src/lib/event-calendar.ts new file mode 100644 index 0000000..224a92f --- /dev/null +++ b/src/lib/event-calendar.ts @@ -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 "); +} diff --git a/src/lib/events.ts b/src/lib/events.ts index 124c895..de19db6 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -141,6 +141,7 @@ export interface WebsiteEvent { organizerWebsite: string | null; coverUrl: string | null; detailsUrl: string | null; + updatedAt: string; } let eventsPromise: Promise | undefined; @@ -243,6 +244,7 @@ async function fetchMeetupFallback(): Promise { organizerWebsite: "https://devcongress.org", coverUrl: meetup.data.cover, detailsUrl: `/meetups/${meetup.id}/`, + updatedAt: meetup.data.start, })); } @@ -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, }; } diff --git a/src/pages/events/calendar.ics.ts b/src/pages/events/calendar.ics.ts new file mode 100644 index 0000000..f85994a --- /dev/null +++ b/src/pages/events/calendar.ics.ts @@ -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", + }, + }); +}; diff --git a/src/pages/events/index.astro b/src/pages/events/index.astro index c87380a..de5d19f 100644 --- a/src/pages/events/index.astro +++ b/src/pages/events/index.astro @@ -1,12 +1,17 @@ --- import Base from '../../layouts/Base.astro'; import { getEvents, sortEventsBySoonest, type WebsiteEvent } from '../../lib/events'; +import { upcomingCalendarEvents } from '../../lib/event-calendar'; const events = sortEventsBySoonest(await getEvents()).map((event) => ({ ...event, status: getEventStatus(event), })); +const calendarFeedUrl = 'https://devcongress.org/events/calendar.ics'; +const googleCalendarUrl = `https://calendar.google.com/calendar/u/0/r?cid=${encodeURIComponent(calendarFeedUrl)}`; +const hasSubscribableEvents = upcomingCalendarEvents(events).length > 0; + function getEventStatus(event: WebsiteEvent): 'upcoming' | 'live' | 'past' { const now = new Date(); const start = new Date(event.startsAt); @@ -20,6 +25,12 @@ function formatDate(iso: string, timezone: string): string { }); } +function formatTime(iso: string, timezone: string): string { + return new Date(iso).toLocaleTimeString('en-GB', { + hour: '2-digit', minute: '2-digit', timeZone: timezone, + }); +} + function formatLabel(value: string): string { if (value === 'conference') return 'Conference / congress'; return value.replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()); @@ -35,11 +46,38 @@ function getEventUrl(event: WebsiteEvent): string | null { return event.detailsUrl ?? event.registrationUrl ?? event.onlineUrl; } +function getCalendarDateKey(iso: string, timezone: string): string { + const parts = new Intl.DateTimeFormat('en-GB', { + year: 'numeric', month: '2-digit', day: '2-digit', timeZone: timezone, + }).formatToParts(new Date(iso)); + const value = (type: Intl.DateTimeFormatPartTypes) => + parts.find((part) => part.type === type)?.value ?? ''; + return `${value('year')}-${value('month')}-${value('day')}`; +} + const statusConfig = { upcoming: { label: 'Upcoming', cta: 'Register →' }, live: { label: '● Live', cta: 'Follow live →' }, past: { label: 'Past', cta: 'View event →' }, }; + +const calendarEvents = events.map((event) => { + const url = getEventUrl(event); + return { + id: event.id, + title: event.title, + startsAt: event.startsAt, + endsAt: event.endsAt, + dateKey: getCalendarDateKey(event.startsAt, event.timezone), + dateLabel: formatDate(event.startsAt, event.timezone), + timeLabel: formatTime(event.startsAt, event.timezone), + format: formatLabel(event.format), + location: getLocation(event), + status: statusConfig[event.status].label, + url, + external: url?.startsWith('http') ?? false, + }; +}); --- Events

Where we show up

-

- {events.length} event{events.length !== 1 ? 's' : ''} and counting. -

+
+

+ {events.length} event{events.length !== 1 ? 's' : ''} and counting. +

+
+ {hasSubscribableEvents ? ( + + + Add to Google Calendar + + + ) : ( + + )} +

+ {hasSubscribableEvents + ? 'Current and upcoming published events. Updates automatically.' + : 'No upcoming events yet. Subscriptions open when the next event is published.'} +

+
+
+
+
+ + + +
+
+ +
    {events.map((event) => { const config = statusConfig[event.status]; @@ -128,10 +225,401 @@ const statusConfig = { ); })}
+
+ +
+ + diff --git a/wrangler.jsonc b/wrangler.jsonc index cb11ff3..30cf625 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -2,6 +2,7 @@ "$schema": "./node_modules/wrangler/config-schema.json", "name": "devcongress-website", "compatibility_date": "2026-07-10", + "preview_urls": true, "observability": { "enabled": true },