From 6bd680cc01840286cfb6c3b866d3e3e702d4d94c Mon Sep 17 00:00:00 2001 From: portdeveloper <108868128+portdeveloper@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:14:41 +0300 Subject: [PATCH 01/10] Add calendar export functionality - Add iCalendar (.ics) generation utility functions - Add "Add to Calendar" button to individual session modals - Add "Add All Events to Calendar" button to schedule view - Support for importing events into Google Calendar, Apple Calendar, Outlook, etc. - Properly handle timezone conversion (Argentina time to UTC) - Style buttons to match page theme with semi-transparent white backgrounds --- .../nextjs/components/ScheduleCalendar.tsx | 15 ++ packages/nextjs/components/SessionModal.tsx | 14 ++ packages/nextjs/utils/calendar.ts | 148 ++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 packages/nextjs/utils/calendar.ts diff --git a/packages/nextjs/components/ScheduleCalendar.tsx b/packages/nextjs/components/ScheduleCalendar.tsx index 99baa80..e5d6000 100644 --- a/packages/nextjs/components/ScheduleCalendar.tsx +++ b/packages/nextjs/components/ScheduleCalendar.tsx @@ -11,7 +11,9 @@ import { getSessionPosition, getSessionsForDay, sessionTypeColors, + sessions, } from "~~/app/sessions"; +import { downloadAllSessionsICS } from "~~/utils/calendar"; export const ScheduleCalendar = () => { const [selectedSession, setSelectedSession] = useState(null); @@ -30,10 +32,23 @@ export const ScheduleCalendar = () => { setSelectedSession(null); }; + const handleAddAllToCalendar = () => { + downloadAllSessionsICS(sessions); + }; + return ( // wrapping div with overflow-x-auto
+
+ +
+
{days.map(day => { diff --git a/packages/nextjs/components/SessionModal.tsx b/packages/nextjs/components/SessionModal.tsx index 4ed0df0..28ec9a8 100644 --- a/packages/nextjs/components/SessionModal.tsx +++ b/packages/nextjs/components/SessionModal.tsx @@ -2,6 +2,7 @@ import Image from "next/image"; import { Session, formatTo12Hour } from "~~/app/sessions"; +import { downloadSessionICS } from "~~/utils/calendar"; interface SessionModalProps { session: Session | null; @@ -12,6 +13,10 @@ interface SessionModalProps { export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) => { if (!session) return null; + const handleAddToCalendar = () => { + downloadSessionICS(session); + }; + // Solid lighter versions of the session colors const lightColors = { workshop: "#B8D4F7", // light blue @@ -66,6 +71,15 @@ export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) => {session.link.text} )} + +
+ +
diff --git a/packages/nextjs/utils/calendar.ts b/packages/nextjs/utils/calendar.ts new file mode 100644 index 0000000..52ecaca --- /dev/null +++ b/packages/nextjs/utils/calendar.ts @@ -0,0 +1,148 @@ +import { Session } from "~~/app/sessions"; + +/** + * Converts a date string and time to ISO format for iCalendar + * @param date - Date string in format YYYY-MM-DD + * @param time - Time string in format HH:MM + * @returns ISO date string in format YYYYMMDDTHHMMSSZ + */ +const toISODateString = (date: string, time: string): string => { + const [year, month, day] = date.split("-"); + const [hours, minutes] = time.split(":"); + + // Create date in local timezone (Argentina) + // Devconnect Argentina is UTC-3 + const localDate = new Date(`${year}-${month}-${day}T${hours}:${minutes}:00`); + + // Convert to UTC for iCalendar format + const utcYear = localDate.getUTCFullYear(); + const utcMonth = String(localDate.getUTCMonth() + 1).padStart(2, "0"); + const utcDay = String(localDate.getUTCDate()).padStart(2, "0"); + const utcHours = String(localDate.getUTCHours()).padStart(2, "0"); + const utcMinutes = String(localDate.getUTCMinutes()).padStart(2, "0"); + + return `${utcYear}${utcMonth}${utcDay}T${utcHours}${utcMinutes}00Z`; +}; + +/** + * Escapes special characters for iCalendar format + */ +const escapeICalText = (text: string): string => { + return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n"); +}; + +/** + * Generates an iCalendar (.ics) file content for a single session + */ +export const generateSessionICS = (session: Session): string => { + const startDateTime = toISODateString(session.date, session.startTime); + const endDateTime = toISODateString(session.date, session.endTime); + + 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`; + + const icsContent = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//BuidlGuidl//Devconnect Builder Bootcamp//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "BEGIN:VEVENT", + `UID:${uid}`, + `DTSTART:${startDateTime}`, + `DTEND:${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 = toISODateString(session.date, session.startTime); + const endDateTime = toISODateString(session.date, session.endTime); + + 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)"; + + const uid = `${session.date}-${session.startTime}-${session.title.replace(/\s+/g, "-")}@devconnect.buidlguidl.com`; + + return [ + "BEGIN:VEVENT", + `UID:${uid}`, + `DTSTART:${startDateTime}`, + `DTEND:${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", + ...events, + "END:VCALENDAR", + ].join("\r\n"); + + return icsContent; +}; + +/** + * Downloads an ICS file + */ +export const downloadICS = (icsContent: string, filename: string): void => { + const blob = new Blob([icsContent], { type: "text/calendar;charset=utf-8" }); + const link = document.createElement("a"); + link.href = window.URL.createObjectURL(blob); + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(link.href); +}; + +/** + * Downloads a single session as ICS file + */ +export const downloadSessionICS = (session: Session): void => { + const icsContent = generateSessionICS(session); + const filename = `${session.title.replace(/\s+/g, "-")}.ics`; + downloadICS(icsContent, filename); +}; + +/** + * Downloads all sessions as a single ICS file + */ +export const downloadAllSessionsICS = (sessions: Session[]): void => { + const icsContent = generateAllSessionsICS(sessions); + downloadICS(icsContent, "BuidlGuidl-Devconnect-All-Sessions.ics"); +}; From 2ee14cfc47d4d3119a7a4414e5d7e3a60ec5bb50 Mon Sep 17 00:00:00 2001 From: portdeveloper <108868128+portdeveloper@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:35:53 +0300 Subject: [PATCH 02/10] Improve calendar export UX and styling - Replace data URI with direct ICS download for better compatibility - Add dropdown menu to individual events (Google Calendar or ICS options) - Simplify "Add all events" button to underlined text link - Add auto-dismissing tooltip on first event (5 second timeout) - Update calendar utility functions to use streamlined approach --- .../nextjs/components/ScheduleCalendar.tsx | 43 ++++++++++------ packages/nextjs/components/SessionModal.tsx | 29 +++++++++-- packages/nextjs/utils/calendar.ts | 50 +++++++++++++++---- 3 files changed, 91 insertions(+), 31 deletions(-) diff --git a/packages/nextjs/components/ScheduleCalendar.tsx b/packages/nextjs/components/ScheduleCalendar.tsx index e5d6000..8d38bc0 100644 --- a/packages/nextjs/components/ScheduleCalendar.tsx +++ b/packages/nextjs/components/ScheduleCalendar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { SessionModal } from "./SessionModal"; import { Session, @@ -13,15 +13,24 @@ import { sessionTypeColors, sessions, } from "~~/app/sessions"; -import { downloadAllSessionsICS } from "~~/utils/calendar"; +import { addAllSessionsToCalendar } from "~~/utils/calendar"; export const ScheduleCalendar = () => { const [selectedSession, setSelectedSession] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); + const [showTooltip, setShowTooltip] = useState(true); const days = getAllDays(); const timeSlots = getHourlyTimeSlotsFormatted(); + useEffect(() => { + const timer = setTimeout(() => { + setShowTooltip(false); + }, 5000); + + return () => clearTimeout(timer); + }, []); + const handleSessionClick = (session: Session) => { setSelectedSession(session); setIsModalOpen(true); @@ -33,22 +42,13 @@ export const ScheduleCalendar = () => { }; const handleAddAllToCalendar = () => { - downloadAllSessionsICS(sessions); + addAllSessionsToCalendar(sessions); }; return ( // wrapping div with overflow-x-auto
-
- -
-
{days.map(day => { @@ -76,14 +76,15 @@ export const ScheduleCalendar = () => {
- {days.map(day => { + {days.map((day, dayIndex) => { const daySessions = getSessionsForDay(day); return (
- {daySessions.map(session => { + {daySessions.map((session, sessionIndex) => { const position = getSessionPosition(session); const colors = sessionTypeColors[session.type]; + const isFirstSession = dayIndex === 0 && sessionIndex === 0; const className = "absolute left-0 right-0 outline outline-2 outline-gray-300 rounded-lg cursor-pointer hover:shadow-md transition-shadow p-2.5 z-10"; const style = { ...colors, top: `${position.startOffset}px`, height: `${position.duration}px` }; @@ -91,9 +92,13 @@ export const ScheduleCalendar = () => { return (
handleSessionClick(session)} + onClick={() => { + handleSessionClick(session); + if (isFirstSession) setShowTooltip(false); + }} + data-tip={isFirstSession ? "Click to add to calendar" : ""} >
@@ -133,6 +138,12 @@ export const ScheduleCalendar = () => {
+
+ +
+
diff --git a/packages/nextjs/components/SessionModal.tsx b/packages/nextjs/components/SessionModal.tsx index 28ec9a8..b017004 100644 --- a/packages/nextjs/components/SessionModal.tsx +++ b/packages/nextjs/components/SessionModal.tsx @@ -1,8 +1,9 @@ "use client"; +import { useState } from "react"; import Image from "next/image"; import { Session, formatTo12Hour } from "~~/app/sessions"; -import { downloadSessionICS } from "~~/utils/calendar"; +import { addSessionToCalendar, getGoogleCalendarUrl } from "~~/utils/calendar"; interface SessionModalProps { session: Session | null; @@ -11,10 +12,18 @@ interface SessionModalProps { } export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) => { + const [showDropdown, setShowDropdown] = useState(false); + if (!session) return null; - const handleAddToCalendar = () => { - downloadSessionICS(session); + const handleGoogleCalendar = () => { + window.open(getGoogleCalendarUrl(session), "_blank"); + setShowDropdown(false); + }; + + const handleICSDownload = () => { + addSessionToCalendar(session); + setShowDropdown(false); }; // Solid lighter versions of the session colors @@ -74,11 +83,23 @@ export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) =>
+ {showDropdown && ( +
+
+ + +
+
+ )}
diff --git a/packages/nextjs/utils/calendar.ts b/packages/nextjs/utils/calendar.ts index 52ecaca..ec842a2 100644 --- a/packages/nextjs/utils/calendar.ts +++ b/packages/nextjs/utils/calendar.ts @@ -117,32 +117,60 @@ export const generateAllSessionsICS = (sessions: Session[]): string => { }; /** - * Downloads an ICS file + * Opens ICS file directly (better UX - no download required) */ -export const downloadICS = (icsContent: string, filename: string): void => { - const blob = new Blob([icsContent], { type: "text/calendar;charset=utf-8" }); +export const openICS = (icsContent: string, filename: string): void => { + // 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 = window.URL.createObjectURL(blob); + link.href = dataUri; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); - window.URL.revokeObjectURL(link.href); }; /** - * Downloads a single session as ICS file + * Adds a single session to calendar */ -export const downloadSessionICS = (session: Session): void => { +export const addSessionToCalendar = (session: Session): void => { const icsContent = generateSessionICS(session); const filename = `${session.title.replace(/\s+/g, "-")}.ics`; - downloadICS(icsContent, filename); + openICS(icsContent, filename); }; /** - * Downloads all sessions as a single ICS file + * Adds all sessions to calendar */ -export const downloadAllSessionsICS = (sessions: Session[]): void => { +export const addAllSessionsToCalendar = (sessions: Session[]): void => { const icsContent = generateAllSessionsICS(sessions); - downloadICS(icsContent, "BuidlGuidl-Devconnect-All-Sessions.ics"); + openICS(icsContent, "BuidlGuidl-Devconnect-All-Sessions.ics"); +}; + +/** + * Generates Google Calendar URL for a session + */ +export const getGoogleCalendarUrl = (session: Session): string => { + const startDate = new Date(`${session.date}T${session.startTime}:00`); + const endDate = new Date(`${session.date}T${session.endTime}:00`); + + // Format dates for Google Calendar (YYYYMMDDTHHmmss) + const formatDateForGoogle = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${year}${month}${day}T${hours}${minutes}00`; + }; + + const speakers = session.speaker?.map(s => s.name).join(", ") || ""; + const speakerText = speakers ? `\n\nSpeaker(s): ${speakers}` : ""; + const details = encodeURIComponent(session.description + speakerText); + const location = encodeURIComponent("Devconnect main venue - Workshop space (Yellow Pavilion)"); + const title = encodeURIComponent(session.title); + + const dates = `${formatDateForGoogle(startDate)}/${formatDateForGoogle(endDate)}`; + + return `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${title}&dates=${dates}&details=${details}&location=${location}&ctz=America/Argentina/Buenos_Aires`; }; From 070c803603ebc0d5d20162636cf05fb4cc4cad2d Mon Sep 17 00:00:00 2001 From: portdeveloper <108868128+portdeveloper@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:38:59 +0300 Subject: [PATCH 03/10] Replace calendar dropdown with simple text links --- packages/nextjs/components/SessionModal.tsx | 30 +++++---------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/packages/nextjs/components/SessionModal.tsx b/packages/nextjs/components/SessionModal.tsx index b017004..c7b209a 100644 --- a/packages/nextjs/components/SessionModal.tsx +++ b/packages/nextjs/components/SessionModal.tsx @@ -1,6 +1,5 @@ "use client"; -import { useState } from "react"; import Image from "next/image"; import { Session, formatTo12Hour } from "~~/app/sessions"; import { addSessionToCalendar, getGoogleCalendarUrl } from "~~/utils/calendar"; @@ -12,18 +11,14 @@ interface SessionModalProps { } export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) => { - const [showDropdown, setShowDropdown] = useState(false); - if (!session) return null; const handleGoogleCalendar = () => { window.open(getGoogleCalendarUrl(session), "_blank"); - setShowDropdown(false); }; const handleICSDownload = () => { addSessionToCalendar(session); - setShowDropdown(false); }; // Solid lighter versions of the session colors @@ -81,25 +76,14 @@ export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) => )} -
- + or + - {showDropdown && ( -
-
- - -
-
- )}
From c13ca892e6a48dbfc217000a6bd164f75d54b698 Mon Sep 17 00:00:00 2001 From: portdeveloper <108868128+portdeveloper@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:09:22 +0300 Subject: [PATCH 04/10] Add pointer cursor to calendar links --- packages/nextjs/components/ScheduleCalendar.tsx | 5 ++++- packages/nextjs/components/SessionModal.tsx | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/components/ScheduleCalendar.tsx b/packages/nextjs/components/ScheduleCalendar.tsx index 8d38bc0..1e9252a 100644 --- a/packages/nextjs/components/ScheduleCalendar.tsx +++ b/packages/nextjs/components/ScheduleCalendar.tsx @@ -139,7 +139,10 @@ export const ScheduleCalendar = () => {
-
diff --git a/packages/nextjs/components/SessionModal.tsx b/packages/nextjs/components/SessionModal.tsx index c7b209a..a970c0b 100644 --- a/packages/nextjs/components/SessionModal.tsx +++ b/packages/nextjs/components/SessionModal.tsx @@ -77,11 +77,17 @@ export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) => )}
- or -
From 6173e7de7124bfbbcd43a8b6ad69af3c6e908e57 Mon Sep 17 00:00:00 2001 From: portdeveloper <108868128+portdeveloper@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:20:08 +0300 Subject: [PATCH 05/10] Fix timezone handling for calendar exports - Use TZID format instead of UTC for ICS files - Add VTIMEZONE block to properly define Argentina timezone - Fix Google Calendar URL to use local time with ctz parameter - Rename toISODateString to formatDateTimeForICS for clarity - Ensures events display correctly regardless of user's timezone --- packages/nextjs/utils/calendar.ts | 72 +++++++++++++++---------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/packages/nextjs/utils/calendar.ts b/packages/nextjs/utils/calendar.ts index ec842a2..db832db 100644 --- a/packages/nextjs/utils/calendar.ts +++ b/packages/nextjs/utils/calendar.ts @@ -1,27 +1,17 @@ import { Session } from "~~/app/sessions"; /** - * Converts a date string and time to ISO format for iCalendar + * 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 ISO date string in format YYYYMMDDTHHMMSSZ + * @returns Formatted date string for iCalendar (YYYYMMDDTHHMMSS) */ -const toISODateString = (date: string, time: string): string => { +const formatDateTimeForICS = (date: string, time: string): string => { const [year, month, day] = date.split("-"); const [hours, minutes] = time.split(":"); - // Create date in local timezone (Argentina) - // Devconnect Argentina is UTC-3 - const localDate = new Date(`${year}-${month}-${day}T${hours}:${minutes}:00`); - - // Convert to UTC for iCalendar format - const utcYear = localDate.getUTCFullYear(); - const utcMonth = String(localDate.getUTCMonth() + 1).padStart(2, "0"); - const utcDay = String(localDate.getUTCDate()).padStart(2, "0"); - const utcHours = String(localDate.getUTCHours()).padStart(2, "0"); - const utcMinutes = String(localDate.getUTCMinutes()).padStart(2, "0"); - - return `${utcYear}${utcMonth}${utcDay}T${utcHours}${utcMinutes}00Z`; + // Return local time format (not UTC) - timezone will be specified separately + return `${year}${month}${day}T${hours}${minutes}00`; }; /** @@ -35,8 +25,8 @@ const escapeICalText = (text: string): string => { * Generates an iCalendar (.ics) file content for a single session */ export const generateSessionICS = (session: Session): string => { - const startDateTime = toISODateString(session.date, session.startTime); - const endDateTime = toISODateString(session.date, session.endTime); + 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}` : ""; @@ -54,10 +44,18 @@ export const generateSessionICS = (session: Session): string => { "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:${startDateTime}`, - `DTEND:${endDateTime}`, + `DTSTART;TZID=America/Argentina/Buenos_Aires:${startDateTime}`, + `DTEND;TZID=America/Argentina/Buenos_Aires:${endDateTime}`, `SUMMARY:${title}`, `DESCRIPTION:${description}`, `LOCATION:${location}`, @@ -75,8 +73,8 @@ export const generateSessionICS = (session: Session): string => { */ export const generateAllSessionsICS = (sessions: Session[]): string => { const events = sessions.map(session => { - const startDateTime = toISODateString(session.date, session.startTime); - const endDateTime = toISODateString(session.date, session.endTime); + 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}` : ""; @@ -90,8 +88,8 @@ export const generateAllSessionsICS = (sessions: Session[]): string => { return [ "BEGIN:VEVENT", `UID:${uid}`, - `DTSTART:${startDateTime}`, - `DTEND:${endDateTime}`, + `DTSTART;TZID=America/Argentina/Buenos_Aires:${startDateTime}`, + `DTEND;TZID=America/Argentina/Buenos_Aires:${endDateTime}`, `SUMMARY:${title}`, `DESCRIPTION:${description}`, `LOCATION:${location}`, @@ -109,6 +107,14 @@ export const generateAllSessionsICS = (sessions: Session[]): string => { "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"); @@ -149,20 +155,13 @@ export const addAllSessionsToCalendar = (sessions: Session[]): void => { /** * Generates Google Calendar URL for a session + * Note: Times are in Argentina timezone (America/Argentina/Buenos_Aires) */ export const getGoogleCalendarUrl = (session: Session): string => { - const startDate = new Date(`${session.date}T${session.startTime}:00`); - const endDate = new Date(`${session.date}T${session.endTime}:00`); - - // Format dates for Google Calendar (YYYYMMDDTHHmmss) - const formatDateForGoogle = (date: Date) => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - return `${year}${month}${day}T${hours}${minutes}00`; - }; + // 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}` : ""; @@ -170,7 +169,8 @@ export const getGoogleCalendarUrl = (session: Session): string => { const location = encodeURIComponent("Devconnect main venue - Workshop space (Yellow Pavilion)"); const title = encodeURIComponent(session.title); - const dates = `${formatDateForGoogle(startDate)}/${formatDateForGoogle(endDate)}`; + 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`; }; From 64a4b1039377161b886de763a06709077612e5a0 Mon Sep 17 00:00:00 2001 From: portdeveloper <108868128+portdeveloper@users.noreply.github.com> Date: Wed, 15 Oct 2025 18:59:58 +0300 Subject: [PATCH 06/10] Refactor: eliminate code duplication in calendar utils - Extract buildEventData() helper function - Remove duplicate event data generation logic - Single source of truth for description, title, location, uid - Reduces code size and improves maintainability --- packages/nextjs/utils/calendar.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/nextjs/utils/calendar.ts b/packages/nextjs/utils/calendar.ts index db832db..64d8383 100644 --- a/packages/nextjs/utils/calendar.ts +++ b/packages/nextjs/utils/calendar.ts @@ -22,12 +22,9 @@ const escapeICalText = (text: string): string => { }; /** - * Generates an iCalendar (.ics) file content for a single session + * Builds event data for iCalendar format */ -export const generateSessionICS = (session: Session): string => { - const startDateTime = formatDateTimeForICS(session.date, session.startTime); - const endDateTime = formatDateTimeForICS(session.date, session.endTime); - +const buildEventData = (session: Session) => { const speakers = session.speaker?.map(s => s.name).join(", ") || ""; const speakerText = speakers ? `\n\nSpeaker(s): ${speakers}` : ""; @@ -38,6 +35,17 @@ export const generateSessionICS = (session: Session): string => { // 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", @@ -75,15 +83,7 @@ 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 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)"; - - const uid = `${session.date}-${session.startTime}-${session.title.replace(/\s+/g, "-")}@devconnect.buidlguidl.com`; + const { description, title, location, uid } = buildEventData(session); return [ "BEGIN:VEVENT", @@ -165,6 +165,7 @@ export const getGoogleCalendarUrl = (session: Session): string => { 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); From b605dbd0ed8bb071eecf6e72dee3744929082996 Mon Sep 17 00:00:00 2001 From: portdeveloper <108868128+portdeveloper@users.noreply.github.com> Date: Wed, 15 Oct 2025 19:09:24 +0300 Subject: [PATCH 07/10] Add comprehensive error handling for calendar functions - Add try-catch blocks to all calendar operations - Validate session data before processing - Protect DOM manipulation with try-finally in openICS - Detect and handle popup blockers for Google Calendar - Show user-friendly error messages with fallback options - Log all errors to console for debugging - Prevent app crashes from malformed data or failed operations --- packages/nextjs/components/SessionModal.tsx | 19 ++++- packages/nextjs/utils/calendar.ts | 92 +++++++++++++++------ 2 files changed, 83 insertions(+), 28 deletions(-) diff --git a/packages/nextjs/components/SessionModal.tsx b/packages/nextjs/components/SessionModal.tsx index a970c0b..0fe861d 100644 --- a/packages/nextjs/components/SessionModal.tsx +++ b/packages/nextjs/components/SessionModal.tsx @@ -14,7 +14,24 @@ export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) => if (!session) return null; const handleGoogleCalendar = () => { - window.open(getGoogleCalendarUrl(session), "_blank"); + 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 = () => { diff --git a/packages/nextjs/utils/calendar.ts b/packages/nextjs/utils/calendar.ts index 64d8383..1205342 100644 --- a/packages/nextjs/utils/calendar.ts +++ b/packages/nextjs/utils/calendar.ts @@ -126,31 +126,59 @@ export const generateAllSessionsICS = (sessions: Session[]): string => { * Opens ICS file directly (better UX - no download required) */ export const openICS = (icsContent: string, filename: string): void => { - // 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); - link.click(); - document.body.removeChild(link); + 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 => { - const icsContent = generateSessionICS(session); - const filename = `${session.title.replace(/\s+/g, "-")}.ics`; - openICS(icsContent, filename); + 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 => { - const icsContent = generateAllSessionsICS(sessions); - openICS(icsContent, "BuidlGuidl-Devconnect-All-Sessions.ics"); + 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."); + } }; /** @@ -158,20 +186,30 @@ export const addAllSessionsToCalendar = (sessions: Session[]): void => { * Note: Times are in Argentina timezone (America/Argentina/Buenos_Aires) */ export const getGoogleCalendarUrl = (session: Session): string => { - // 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); + try { + if (!session || !session.title || !session.date || !session.startTime || !session.endTime) { + throw new Error("Invalid session data"); + } - 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}`; + // 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); - // 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`; + 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 ""; + } }; From 268496bcddadd07012aa1134446976506d68d805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Thu, 16 Oct 2025 09:53:08 +0200 Subject: [PATCH 08/10] remove tooltip hint --- .../nextjs/components/ScheduleCalendar.tsx | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/packages/nextjs/components/ScheduleCalendar.tsx b/packages/nextjs/components/ScheduleCalendar.tsx index 1e9252a..2043f63 100644 --- a/packages/nextjs/components/ScheduleCalendar.tsx +++ b/packages/nextjs/components/ScheduleCalendar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { SessionModal } from "./SessionModal"; import { Session, @@ -18,19 +18,10 @@ import { addAllSessionsToCalendar } from "~~/utils/calendar"; export const ScheduleCalendar = () => { const [selectedSession, setSelectedSession] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); - const [showTooltip, setShowTooltip] = useState(true); const days = getAllDays(); const timeSlots = getHourlyTimeSlotsFormatted(); - useEffect(() => { - const timer = setTimeout(() => { - setShowTooltip(false); - }, 5000); - - return () => clearTimeout(timer); - }, []); - const handleSessionClick = (session: Session) => { setSelectedSession(session); setIsModalOpen(true); @@ -76,15 +67,14 @@ export const ScheduleCalendar = () => {
- {days.map((day, dayIndex) => { + {days.map(day => { const daySessions = getSessionsForDay(day); return (
- {daySessions.map((session, sessionIndex) => { + {daySessions.map(session => { const position = getSessionPosition(session); const colors = sessionTypeColors[session.type]; - const isFirstSession = dayIndex === 0 && sessionIndex === 0; const className = "absolute left-0 right-0 outline outline-2 outline-gray-300 rounded-lg cursor-pointer hover:shadow-md transition-shadow p-2.5 z-10"; const style = { ...colors, top: `${position.startOffset}px`, height: `${position.duration}px` }; @@ -92,13 +82,9 @@ export const ScheduleCalendar = () => { return (
{ - handleSessionClick(session); - if (isFirstSession) setShowTooltip(false); - }} - data-tip={isFirstSession ? "Click to add to calendar" : ""} + onClick={() => handleSessionClick(session)} >
From 90c384ed183aef0e3ed4785da264a882d2d4082c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Thu, 16 Oct 2025 09:54:56 +0200 Subject: [PATCH 09/10] underline online the text --- packages/nextjs/components/SessionModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/components/SessionModal.tsx b/packages/nextjs/components/SessionModal.tsx index 0fe861d..b286e25 100644 --- a/packages/nextjs/components/SessionModal.tsx +++ b/packages/nextjs/components/SessionModal.tsx @@ -96,9 +96,9 @@ export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) =>
or
diff --git a/packages/nextjs/components/SessionModal.tsx b/packages/nextjs/components/SessionModal.tsx index b286e25..0744938 100644 --- a/packages/nextjs/components/SessionModal.tsx +++ b/packages/nextjs/components/SessionModal.tsx @@ -50,8 +50,11 @@ export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) => }; return ( -
-
+
+

{session.title}

@@ -86,14 +89,16 @@ export const SessionModal = ({ session, isOpen, onClose }: SessionModalProps) =>
-

{session.description}

+

{session.description}

{session.link && ( - + {session.link.text} )} -
+
+ +