Skip to content
15 changes: 15 additions & 0 deletions packages/nextjs/components/ScheduleCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
getSessionPosition,
getSessionsForDay,
sessionTypeColors,
sessions,
} from "~~/app/sessions";
import { addAllSessionsToCalendar } from "~~/utils/calendar";

export const ScheduleCalendar = () => {
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
Expand All @@ -30,6 +32,10 @@ export const ScheduleCalendar = () => {
setSelectedSession(null);
};

const handleAddAllToCalendar = () => {
addAllSessionsToCalendar(sessions);
};

return (
// wrapping div with overflow-x-auto
<div className="overflow-x-auto">
Expand Down Expand Up @@ -118,6 +124,15 @@ export const ScheduleCalendar = () => {
</div>
</div>

<div className="flex justify-center mt-8">
<button
onClick={handleAddAllToCalendar}
className="text-base-content/70 hover:text-base-content cursor-pointer"
>
📥 <span className="underline">Download ICS file with all events</span>
</button>
</div>

<SessionModal session={selectedSession} isOpen={isModalOpen} onClose={handleCloseModal} />
</div>
</div>
Expand Down
55 changes: 51 additions & 4 deletions packages/nextjs/components/SessionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import Image from "next/image";
import { Session, formatTo12Hour } from "~~/app/sessions";
import { addSessionToCalendar, getGoogleCalendarUrl } from "~~/utils/calendar";

interface SessionModalProps {
session: Session | null;
Expand All @@ -12,6 +13,31 @@ interface SessionModalProps {
export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) => {
if (!session) return null;

const handleGoogleCalendar = () => {
try {
const url = getGoogleCalendarUrl(session);
if (!url) {
alert("Unable to generate Google Calendar link. Please try the ICS download option.");
return;
}

const newWindow = window.open(url, "_blank");
if (!newWindow || newWindow.closed || typeof newWindow.closed === "undefined") {
// Popup was blocked
alert(
"Popup blocked! Please allow popups for this site, or copy this link:\n\n" + url.substring(0, 100) + "...",
);
}
} catch (error) {
console.error("Failed to open Google Calendar:", error);
alert("Unable to open Google Calendar. Please try the ICS download option.");
}
};

const handleICSDownload = () => {
addSessionToCalendar(session);
};

// Solid lighter versions of the session colors
const lightColors = {
workshop: "#B8D4F7", // light blue
Expand All @@ -24,8 +50,11 @@ export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) =>
};

return (
<div className={`modal ${isOpen ? "modal-open" : ""}`}>
<div className="modal-box max-w-2xl" style={{ backgroundColor: lightColors[session.type] }}>
<div className={`modal ${isOpen ? "modal-open" : ""} items-start sm:items-center pt-16 sm:pt-0`}>
<div
className="modal-box max-w-2xl max-h-[85vh] overflow-y-auto"
style={{ backgroundColor: lightColors[session.type] }}
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="font-bold text-lg text-primary mb-0">{session.title}</h3>
Expand Down Expand Up @@ -60,12 +89,30 @@ export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) =>

<div className="divider"></div>

<p className="text-base leading-relaxed whitespace-pre-line">{session.description}</p>
<p className="text-base leading-relaxed whitespace-pre-line mb-0">{session.description}</p>
{session.link && (
<a href={session.link.url} target="_blank" rel="noopener noreferrer" className="link">
<a href={session.link.url} target="_blank" rel="noopener noreferrer" className="btn btn-primary text-white">
{session.link.text}
</a>
)}

<div className="divider"></div>

<div className="flex gap-4 items-center text-sm">
<button
onClick={handleGoogleCalendar}
className="text-base-content/70 hover:text-base-content cursor-pointer"
>
📅 <span className="underline">Add to Google Calendar</span>
</button>
<span className="text-base-content/40">or</span>
<button
onClick={handleICSDownload}
className="text-base-content/70 hover:text-base-content underline cursor-pointer"
>
Download ICS file
</button>
</div>
</div>
<div className="modal-backdrop" onClick={onClose}></div>
</div>
Expand Down
215 changes: 215 additions & 0 deletions packages/nextjs/utils/calendar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { Session } from "~~/app/sessions";

/**
* Formats date and time for iCalendar with timezone
* @param date - Date string in format YYYY-MM-DD
* @param time - Time string in format HH:MM
* @returns Formatted date string for iCalendar (YYYYMMDDTHHMMSS)
*/
const formatDateTimeForICS = (date: string, time: string): string => {
const [year, month, day] = date.split("-");
const [hours, minutes] = time.split(":");

// Return local time format (not UTC) - timezone will be specified separately
return `${year}${month}${day}T${hours}${minutes}00`;
};

/**
* Escapes special characters for iCalendar format
*/
const escapeICalText = (text: string): string => {
return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
};

/**
* Builds event data for iCalendar format
*/
const buildEventData = (session: Session) => {
const speakers = session.speaker?.map(s => s.name).join(", ") || "";
const speakerText = speakers ? `\n\nSpeaker(s): ${speakers}` : "";

const description = escapeICalText(session.description + speakerText);
const title = escapeICalText(session.title);
const location = "Devconnect main venue - Workshop space (Yellow Pavilion)";

// Generate unique ID for the event
const uid = `${session.date}-${session.startTime}-${session.title.replace(/\s+/g, "-")}@devconnect.buidlguidl.com`;

return { description, title, location, uid };
};

/**
* Generates an iCalendar (.ics) file content for a single session
*/
export const generateSessionICS = (session: Session): string => {
const startDateTime = formatDateTimeForICS(session.date, session.startTime);
const endDateTime = formatDateTimeForICS(session.date, session.endTime);
const { description, title, location, uid } = buildEventData(session);

const icsContent = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//BuidlGuidl//Devconnect Builder Bootcamp//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VTIMEZONE",
"TZID:America/Argentina/Buenos_Aires",
"BEGIN:STANDARD",
"DTSTART:19700101T000000",
"TZOFFSETFROM:-0300",
"TZOFFSETTO:-0300",
"END:STANDARD",
"END:VTIMEZONE",
"BEGIN:VEVENT",
`UID:${uid}`,
`DTSTART;TZID=America/Argentina/Buenos_Aires:${startDateTime}`,
`DTEND;TZID=America/Argentina/Buenos_Aires:${endDateTime}`,
`SUMMARY:${title}`,
`DESCRIPTION:${description}`,
`LOCATION:${location}`,
"STATUS:CONFIRMED",
"SEQUENCE:0",
"END:VEVENT",
"END:VCALENDAR",
].join("\r\n");

return icsContent;
};

/**
* Generates an iCalendar (.ics) file content for all sessions
*/
export const generateAllSessionsICS = (sessions: Session[]): string => {
const events = sessions.map(session => {
const startDateTime = formatDateTimeForICS(session.date, session.startTime);
const endDateTime = formatDateTimeForICS(session.date, session.endTime);
const { description, title, location, uid } = buildEventData(session);

return [
"BEGIN:VEVENT",
`UID:${uid}`,
`DTSTART;TZID=America/Argentina/Buenos_Aires:${startDateTime}`,
`DTEND;TZID=America/Argentina/Buenos_Aires:${endDateTime}`,
`SUMMARY:${title}`,
`DESCRIPTION:${description}`,
`LOCATION:${location}`,
"STATUS:CONFIRMED",
"SEQUENCE:0",
"END:VEVENT",
].join("\r\n");
});

const icsContent = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//BuidlGuidl//Devconnect Builder Bootcamp//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"X-WR-CALNAME:BuidlGuidl Builder Bootcamp @ Devconnect",
"X-WR-TIMEZONE:America/Argentina/Buenos_Aires",
"BEGIN:VTIMEZONE",
"TZID:America/Argentina/Buenos_Aires",
"BEGIN:STANDARD",
"DTSTART:19700101T000000",
"TZOFFSETFROM:-0300",
"TZOFFSETTO:-0300",
"END:STANDARD",
"END:VTIMEZONE",
...events,
"END:VCALENDAR",
].join("\r\n");

return icsContent;
};

/**
* Opens ICS file directly (better UX - no download required)
*/
export const openICS = (icsContent: string, filename: string): void => {
try {
// Use data URI to open calendar app directly without downloading
const dataUri = `data:text/calendar;charset=utf-8,${encodeURIComponent(icsContent)}`;
const link = document.createElement("a");
link.href = dataUri;
link.download = filename;
document.body.appendChild(link);

try {
link.click();
} finally {
// Always clean up DOM element, even if click fails
document.body.removeChild(link);
}
} catch (error) {
console.error("Failed to download calendar file:", error);
alert("Unable to download calendar file. Please try again or use Google Calendar option.");
}
};

/**
* Adds a single session to calendar
*/
export const addSessionToCalendar = (session: Session): void => {
try {
if (!session || !session.title || !session.date || !session.startTime || !session.endTime) {
throw new Error("Invalid session data");
}

const icsContent = generateSessionICS(session);
const filename = `${session.title.replace(/\s+/g, "-")}.ics`;
openICS(icsContent, filename);
} catch (error) {
console.error("Failed to add session to calendar:", error);
alert("Unable to add event to calendar. Please try again.");
}
};

/**
* Adds all sessions to calendar
*/
export const addAllSessionsToCalendar = (sessions: Session[]): void => {
try {
if (!sessions || sessions.length === 0) {
throw new Error("No sessions to add");
}

const icsContent = generateAllSessionsICS(sessions);
openICS(icsContent, "BuidlGuidl-Devconnect-All-Sessions.ics");
} catch (error) {
console.error("Failed to add all sessions to calendar:", error);
alert("Unable to add events to calendar. Please try again.");
}
};

/**
* Generates Google Calendar URL for a session
* Note: Times are in Argentina timezone (America/Argentina/Buenos_Aires)
*/
export const getGoogleCalendarUrl = (session: Session): string => {
try {
if (!session || !session.title || !session.date || !session.startTime || !session.endTime) {
throw new Error("Invalid session data");
}

// Google Calendar dates format: YYYYMMDDTHHmmss
// We use the raw date/time values which represent Argentina local time
const startDateTime = formatDateTimeForICS(session.date, session.startTime);
const endDateTime = formatDateTimeForICS(session.date, session.endTime);

const speakers = session.speaker?.map(s => s.name).join(", ") || "";
const speakerText = speakers ? `\n\nSpeaker(s): ${speakers}` : "";
// Note: For Google Calendar, we don't escape - we URL encode instead
const details = encodeURIComponent(session.description + speakerText);
const location = encodeURIComponent("Devconnect main venue - Workshop space (Yellow Pavilion)");
const title = encodeURIComponent(session.title);

const dates = `${startDateTime}/${endDateTime}`;

// ctz parameter tells Google Calendar the timezone for the event
return `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${title}&dates=${dates}&details=${details}&location=${location}&ctz=America/Argentina/Buenos_Aires`;
} catch (error) {
console.error("Failed to generate Google Calendar URL:", error);
// Return a fallback URL or empty string
return "";
}
};