/** * Lightweight iCalendar (.ics) generation for skate tours & events. * * No external deps — we build a spec-compliant VEVENT string and hand it to the * browser as a downloadable file so attendees can drop a tour straight into * Apple Calendar, Google Calendar, Outlook, etc. */ export interface CalendarEventInput { /** Stable unique id (we use the Nostr addressable coordinate). */ uid: string; title: string; description?: string; location?: string; /** Start time in unix seconds. */ start: number; /** Optional end time in unix seconds. If omitted, defaults to start + 2h. */ end?: number; /** Optional URL to include in the event body. */ url?: string; /** When true, the event is treated as an all-day / multi-day date range. */ allDay?: boolean; } /** Escape text per RFC 5545 (commas, semicolons, backslashes, newlines). */ function escapeICS(text: string): string { return text .replace(/\\/g, '\\\\') .replace(/;/g, '\\;') .replace(/,/g, '\\,') .replace(/\r?\n/g, '\\n'); } /** Format unix seconds to a UTC timestamp: YYYYMMDDTHHMMSSZ */ function toICSDateTime(secs: number): string { const d = new Date(secs * 1000); return ( d.getUTCFullYear().toString().padStart(4, '0') + (d.getUTCMonth() + 1).toString().padStart(2, '0') + d.getUTCDate().toString().padStart(2, '0') + 'T' + d.getUTCHours().toString().padStart(2, '0') + d.getUTCMinutes().toString().padStart(2, '0') + d.getUTCSeconds().toString().padStart(2, '0') + 'Z' ); } /** Format unix seconds to an all-day date value: YYYYMMDD (local calendar day). */ function toICSDate(secs: number): string { const d = new Date(secs * 1000); return ( d.getFullYear().toString().padStart(4, '0') + (d.getMonth() + 1).toString().padStart(2, '0') + d.getDate().toString().padStart(2, '0') ); } /** Build a full VCALENDAR document containing a single VEVENT. */ export function buildICS(input: CalendarEventInput): string { const now = toICSDateTime(Math.floor(Date.now() / 1000)); const lines: string[] = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//SkateSpots//Tours//EN', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'BEGIN:VEVENT', `UID:${escapeICS(input.uid)}@skate-spots.org`, `DTSTAMP:${now}`, ]; if (input.allDay) { // All-day: DTEND is exclusive, so add one day to the last calendar day. const endSecs = input.end ?? input.start; lines.push(`DTSTART;VALUE=DATE:${toICSDate(input.start)}`); lines.push(`DTEND;VALUE=DATE:${toICSDate(endSecs + 24 * 60 * 60)}`); } else { const endSecs = input.end ?? input.start + 2 * 60 * 60; lines.push(`DTSTART:${toICSDateTime(input.start)}`); lines.push(`DTEND:${toICSDateTime(endSecs)}`); } lines.push(`SUMMARY:${escapeICS(input.title)}`); const descParts: string[] = []; if (input.description) descParts.push(input.description); if (input.url) descParts.push(input.url); if (descParts.length > 0) { lines.push(`DESCRIPTION:${escapeICS(descParts.join('\n\n'))}`); } if (input.location) lines.push(`LOCATION:${escapeICS(input.location)}`); if (input.url) lines.push(`URL:${escapeICS(input.url)}`); lines.push('END:VEVENT', 'END:VCALENDAR'); return lines.join('\r\n'); } /** Trigger a browser download of an .ics file for the given event. */ export function downloadICS(input: CalendarEventInput, filename?: string): void { const ics = buildICS(input); const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename ?? `${slug(input.title) || 'skate-tour'}.ics`; document.body.appendChild(a); a.click(); document.body.removeChild(a); // Give the browser a tick to start the download before revoking. setTimeout(() => URL.revokeObjectURL(url), 1000); } /** Build a Google Calendar "add event" URL as a fallback for the web. */ export function googleCalendarUrl(input: CalendarEventInput): string { const fmt = input.allDay ? toICSDate : toICSDateTime; const endSecs = input.allDay ? (input.end ?? input.start) + 24 * 60 * 60 : input.end ?? input.start + 2 * 60 * 60; const params = new URLSearchParams({ action: 'TEMPLATE', text: input.title, dates: `${fmt(input.start)}/${fmt(endSecs)}`, }); if (input.description || input.url) { params.set('details', [input.description, input.url].filter(Boolean).join('\n\n')); } if (input.location) params.set('location', input.location); return `https://calendar.google.com/calendar/render?${params.toString()}`; } function slug(s: string): string { return s .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') .slice(0, 50); }