6513bfa6ee
Hand-rolled a minimal RFC 5545 writer (lib/ics.ts) rather than adding a dependency for what's a handful of VEVENTs. Meal slots have no stored time-of-day, so each mealType maps to a conventional wall-clock time (dinner 7pm, etc.) written as floating local time, not pinned to a timezone. Authenticated-only download for now, not a subscribe URL — that would need a new unguessable-link mechanism on meal plans, which don't have one today (unlike shopping lists' isPublic). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
71 lines
2.5 KiB
TypeScript
71 lines
2.5 KiB
TypeScript
// 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";
|
|
}
|