import { Calendar, CalendarEvent, CalendarEventAttendee } from '../types.ts'; export const CALENDAR_COLOR_OPTIONS = [ 'bg-red-700', 'bg-red-950', 'bg-orange-700', 'bg-orange-950', 'bg-amber-700', 'bg-yellow-800', 'bg-lime-700', 'bg-lime-950', 'bg-green-700', 'bg-emerald-800', 'bg-teal-700', 'bg-cyan-700', 'bg-sky-800', 'bg-blue-900', 'bg-indigo-700', 'bg-violet-700', 'bg-purple-800', 'bg-fuchsia-700', 'bg-pink-800', 'bg-rose-700', ] as const; // NOTE: This variable isn't really used, _but_ it allows for tailwind to include the classes without having to move this into the tailwind.config.ts file export const CALENDAR_BORDER_COLOR_OPTIONS = [ 'border-red-700', 'border-red-950', 'border-orange-700', 'border-orange-950', 'border-amber-700', 'border-yellow-800', 'border-lime-700', 'border-lime-950', 'border-green-700', 'border-emerald-800', 'border-teal-700', 'border-cyan-700', 'border-sky-800', 'border-blue-900', 'border-indigo-700', 'border-violet-700', 'border-purple-800', 'border-fuchsia-700', 'border-pink-800', 'border-rose-700', ] as const; function getVCalendarAttendeeStatus(status: CalendarEventAttendee['status']) { if (status === 'accepted' || status === 'rejected') { return status.toUpperCase(); } return `NEEDS-ACTION`; } function getAttendeeStatusFromVCalendar( status: 'NEEDS-ACTION' | 'ACCEPTED' | 'REJECTED', ): CalendarEventAttendee['status'] { if (status === 'ACCEPTED' || status === 'REJECTED') { return status.toLowerCase() as CalendarEventAttendee['status']; } return 'invited'; } // TODO: Build this export function formatCalendarEventsToVCalendar( calendarEvents: CalendarEvent[], calendars: Pick[], ): string { const vCalendarText = calendarEvents.map((calendarEvent) => `BEGIN:VEVENT DTSTAMP:${new Date(calendarEvent.created_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')} DTSTART:${new Date(calendarEvent.start_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')} DTEND:${new Date(calendarEvent.end_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')} ORGANIZER;CN=:mailto:${calendarEvent.extra.organizer_email} SUMMARY:${calendarEvent.title} TRANSP:${getCalendarEventTransparency(calendarEvent, calendars).toUpperCase()} ${calendarEvent.extra.uid ? `UID:${calendarEvent.extra.uid}` : ''} ${ calendarEvent.extra.attendees?.map((attendee) => `ATTENDEE;PARTSTAT=${getVCalendarAttendeeStatus(attendee.status)};CN=${ attendee.name || '' }:mailto:${attendee.email}` ).join('\n') || '' } END:VEVENT` ).join('\n'); return `BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\n${vCalendarText}\nEND:VCALENDAR`.split('\n').map((line) => line.trim() ).filter( Boolean, ).join('\n'); } type VCalendarVersion = '1.0' | '2.0'; export function parseVCalendarFromTextContents(text: string): Partial[] { // Lines that start with a space should be moved to the line above them, as it's the same field/value to parse const lines = text.split('\n').reduce((previousLines, currentLine) => { if (currentLine.startsWith(' ')) { previousLines[previousLines.length - 1] = `${previousLines[previousLines.length - 1]}${ currentLine.substring(1).replaceAll('\r', '') }`; } else { previousLines.push(currentLine.replaceAll('\r', '')); } return previousLines; }, [] as string[]).map((line) => line.trim()).filter(Boolean); const partialCalendarEvents: Partial[] = []; let partialCalendarEvent: Partial = {}; let vCalendarVersion: VCalendarVersion = '2.0'; // Loop through every line for (const line of lines) { // Start new vCard version if (line.startsWith('BEGIN:VCALENDAR')) { vCalendarVersion = '2.0'; continue; } // Start new event if (line.startsWith('BEGIN:VEVENT')) { partialCalendarEvent = {}; continue; } // Finish event if (line.startsWith('END:VEVENT')) { partialCalendarEvents.push(partialCalendarEvent); continue; } // Select proper vCalendar version if (line.startsWith('VERSION:')) { if (line.startsWith('VERSION:1.0')) { vCalendarVersion = '1.0'; } else if (line.startsWith('VERSION:2.0')) { vCalendarVersion = '2.0'; } else { // Default to 2.0, log warning vCalendarVersion = '2.0'; console.warn(`Invalid vCalendar version found: "${line}". Defaulting to 2.0 parser.`); } continue; } if (vCalendarVersion !== '1.0' && vCalendarVersion !== '2.0') { console.warn(`Invalid vCalendar version found: "${vCalendarVersion}". Defaulting to 2.0 parser.`); vCalendarVersion = '2.0'; } if (line.startsWith('UID:')) { const uid = line.replace('UID:', ''); if (!uid) { continue; } partialCalendarEvent.extra = { ...(partialCalendarEvent.extra! || {}), uid, }; continue; } // TODO: Build this ( https://en.wikipedia.org/wiki/ICalendar#List_of_components,_properties,_and_parameters ) if (line.startsWith('SUMMARY:')) { const title = line.split('SUMMARY:')[1] || ''; partialCalendarEvent.title = title; continue; } if (line.startsWith('DTSTART:') || line.startsWith('DTSTART;')) { const startDateInfo = line.split(':')[1] || ''; const [dateInfo, hourInfo] = startDateInfo.split('T'); const year = dateInfo.substring(0, 4); const month = dateInfo.substring(4, 6); const day = dateInfo.substring(6, 8); const hours = hourInfo.substring(0, 2); const minutes = hourInfo.substring(2, 4); const seconds = hourInfo.substring(4, 6); const startDate = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000Z`); partialCalendarEvent.start_date = startDate; continue; } if (line.startsWith('DTEND:') || line.startsWith('DTEND;')) { const endDateInfo = line.split(':')[1] || ''; const [dateInfo, hourInfo] = endDateInfo.split('T'); const year = dateInfo.substring(0, 4); const month = dateInfo.substring(4, 6); const day = dateInfo.substring(6, 8); const hours = hourInfo.substring(0, 2); const minutes = hourInfo.substring(2, 4); const seconds = hourInfo.substring(4, 6); const endDate = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000Z`); partialCalendarEvent.end_date = endDate; continue; } if (line.startsWith('ORGANIZER;')) { const organizerInfo = line.split(':'); const organizerEmail = organizerInfo.slice(-1)[0] || ''; if (!organizerEmail) { continue; } partialCalendarEvent.extra = { ...(partialCalendarEvent.extra! || {}), organizer_email: organizerEmail, }; } if (line.startsWith('TRANSP:')) { const transparency = (line.split('TRANSP:')[1] || 'default') .toLowerCase() as CalendarEvent['extra']['transparency']; partialCalendarEvent.extra = { ...(partialCalendarEvent.extra! || {}), transparency, }; continue; } if (line.startsWith('ATTENDEE;')) { const attendeeInfo = line.split(':'); const attendeeEmail = attendeeInfo.slice(-1)[0] || ''; const attendeeStatusInfo = line.split('PARTSTAT=')[1] || ''; const attendeeStatus = getAttendeeStatusFromVCalendar( (attendeeStatusInfo.split(';')[0] || 'NEEDS-ACTION') as 'ACCEPTED' | 'REJECTED' | 'NEEDS-ACTION', ); const attendeeNameInfo = line.split('CN=')[1] || ''; const attendeeName = (attendeeNameInfo.split(';')[0] || '').trim(); if (!attendeeEmail) { continue; } const attendee: CalendarEventAttendee = { email: attendeeEmail, status: attendeeStatus, }; if (attendeeName) { attendee.name = attendeeName; } partialCalendarEvent.extra = { ...(partialCalendarEvent.extra! || {}), attendees: [...(partialCalendarEvent.extra?.attendees || []), attendee], }; } } return partialCalendarEvents; } // NOTE: Considers weeks starting Monday, not Sunday export function getWeeksForMonth(date: Date): { date: Date; isSameMonth: boolean }[][] { const year = date.getFullYear(); const month = date.getMonth(); const firstOfMonth = new Date(year, month, 1); const lastOfMonth = new Date(year, month + 1, 0); const daysToShow = firstOfMonth.getDay() + (firstOfMonth.getDay() === 0 ? 6 : -1) + lastOfMonth.getDate(); const weekCount = Math.ceil(daysToShow / 7); const weeks: { date: Date; isSameMonth: boolean }[][] = []; const startingDate = new Date(firstOfMonth); startingDate.setDate( startingDate.getDate() - Math.abs(firstOfMonth.getDay() === 0 ? 6 : (firstOfMonth.getDay() - 1)), ); for (let weekIndex = 0; weeks.length < weekCount; ++weekIndex) { for (let dayIndex = 0; dayIndex < 7; ++dayIndex) { if (!Array.isArray(weeks[weekIndex])) { weeks[weekIndex] = []; } const weekDayDate = new Date(startingDate); weekDayDate.setDate(weekDayDate.getDate() + (dayIndex + weekIndex * 7)); const isSameMonth = weekDayDate.getMonth() === month; weeks[weekIndex].push({ date: weekDayDate, isSameMonth }); } } return weeks; } // NOTE: Considers week starting Monday, not Sunday export function getDaysForWeek( date: Date, ): { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] { const shortIsoDate = date.toISOString().substring(0, 10); const currentHour = new Date().getHours(); const days: { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] = []; const startingDate = new Date(date); startingDate.setDate( startingDate.getDate() - Math.abs(startingDate.getDay() === 0 ? 6 : (startingDate.getDay() - 1)), ); for (let dayIndex = 0; days.length < 7; ++dayIndex) { const dayDate = new Date(startingDate); dayDate.setDate(dayDate.getDate() + dayIndex); const isSameDay = dayDate.toISOString().substring(0, 10) === shortIsoDate; days[dayIndex] = { date: dayDate, isSameDay, hours: [], }; for (let hourIndex = 0; hourIndex < 24; ++hourIndex) { const dayHourDate = new Date(dayDate); dayHourDate.setHours(hourIndex); const isCurrentHour = isSameDay && hourIndex === currentHour; days[dayIndex].hours.push({ date: dayHourDate, isCurrentHour }); } } return days; } function getCalendarEventTransparency( calendarEvent: CalendarEvent, calendars: Pick[], ) { const matchingCalendar = calendars.find((calendar) => calendar.id === calendarEvent.calendar_id); const transparency = calendarEvent.extra.transparency === 'default' ? (matchingCalendar?.extra.default_transparency || 'opaque') : calendarEvent.extra.transparency; return transparency; } export function getCalendarEventColor( calendarEvent: CalendarEvent, calendars: Pick[], ) { const matchingCalendar = calendars.find((calendar) => calendar.id === calendarEvent.calendar_id); const opaqueColor = matchingCalendar?.color || 'bg-gray-700'; const transparentColor = opaqueColor.replace('bg-', 'border border-'); const transparency = getCalendarEventTransparency(calendarEvent, calendars); return transparency === 'opaque' ? opaqueColor : transparentColor; }