// Minimal hand-rolled iCalendar (RFC 5545) writer — the meal plan only needs // a handful of all-day-ish VEVENTs, not enough to justify a dependency. export type IcsEvent = { uid: string; title: string; description?: string; start: Date; durationMinutes: number; }; function foldLine(line: string): string { // RFC 5545 §3.1: lines over 75 octets should be folded with a leading space // on the continuation — not strictly required for readers to work, but // keeps long recipe titles/descriptions spec-compliant. if (line.length <= 75) return line; const chunks: string[] = []; let rest = line; while (rest.length > 75) { chunks.push(rest.slice(0, 75)); rest = " " + rest.slice(75); } chunks.push(rest); return chunks.join("\r\n"); } function escapeText(text: string): string { return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n"); } // Floating local time (no trailing "Z", no UTC conversion) — meal times have // no stored timezone, so this is authored as "7pm wherever you're looking at // this calendar" rather than pinned to one instant, per RFC 5545 §3.3.5. function formatDateTime(d: Date): string { const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } function formatUtcStamp(d: Date): string { const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`; } export function buildIcs(calendarName: string, events: IcsEvent[]): string { const now = formatUtcStamp(new Date()); const lines: string[] = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Epicure//Meal Plan//EN", "CALSCALE:GREGORIAN", foldLine(`X-WR-CALNAME:${escapeText(calendarName)}`), ]; for (const event of events) { const end = new Date(event.start.getTime() + event.durationMinutes * 60_000); lines.push( "BEGIN:VEVENT", `UID:${event.uid}`, `DTSTAMP:${now}`, `DTSTART:${formatDateTime(event.start)}`, `DTEND:${formatDateTime(end)}`, foldLine(`SUMMARY:${escapeText(event.title)}`) ); if (event.description) lines.push(foldLine(`DESCRIPTION:${escapeText(event.description)}`)); lines.push("END:VEVENT"); } lines.push("END:VCALENDAR"); return lines.join("\r\n") + "\r\n"; }