Files
Arnaud 6513bfa6ee feat: export meal plan to a calendar (.ics) file
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>
2026-07-13 22:22:33 +02:00

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";
}