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>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.18.0";
|
||||
export const APP_VERSION = "0.19.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.19.0",
|
||||
date: "2026-07-13 22:21",
|
||||
added: [
|
||||
"**Export your meal plan to a calendar file** — download a week's meals as a .ics file to import into Google/Apple/Outlook calendar.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.18.0",
|
||||
date: "2026-07-13 22:16",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// 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";
|
||||
}
|
||||
Reference in New Issue
Block a user