From 6513bfa6ee22032e55a3d36934e27008411e4a7c Mon Sep 17 00:00:00 2001 From: Arnaud Date: Mon, 13 Jul 2026 22:22:33 +0200 Subject: [PATCH] feat: export meal plan to a calendar (.ics) file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 5 ++ apps/web/app/(app)/meal-plan/page.tsx | 6 +- .../[weekStart]/export/ics/route.ts | 59 ++++++++++++++++ apps/web/lib/changelog.ts | 9 ++- apps/web/lib/ics.ts | 70 +++++++++++++++++++ apps/web/messages/en.json | 1 + apps/web/messages/fr.json | 1 + apps/web/package.json | 2 +- package.json | 2 +- 9 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 apps/web/app/api/v1/meal-plans/[weekStart]/export/ics/route.ts create mode 100644 apps/web/lib/ics.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 74b753b..e73d291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.19.0 — 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. + ## 0.18.0 — 2026-07-13 22:16 ### Added diff --git a/apps/web/app/(app)/meal-plan/page.tsx b/apps/web/app/(app)/meal-plan/page.tsx index 1860be8..fbd330e 100644 --- a/apps/web/app/(app)/meal-plan/page.tsx +++ b/apps/web/app/(app)/meal-plan/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { headers } from "next/headers"; import Link from "next/link"; -import { ChevronLeft, ChevronRight, ShoppingCart, Printer } from "lucide-react"; +import { ChevronLeft, ChevronRight, ShoppingCart, Printer, CalendarDays } from "lucide-react"; import { auth } from "@/lib/auth/server"; import { db, mealPlans, mealPlanMembers, recipes, userNutritionGoals, eq, and, desc } from "@epicure/db"; import { buttonVariants } from "@/components/ui/button"; @@ -133,6 +133,10 @@ export default async function MealPlanPage({ markdown={mealPlanToMarkdown({ label, entries })} filename={`meal-plan-${weekStart}`} /> + + + {msgs.mealPlan.exportCalendar} + diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/export/ics/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/export/ics/route.ts new file mode 100644 index 0000000..374a747 --- /dev/null +++ b/apps/web/app/api/v1/meal-plans/[weekStart]/export/ics/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, mealPlans, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { buildIcs, type IcsEvent } from "@/lib/ics"; + +type Params = { params: Promise<{ weekStart: string }> }; + +const DAY_OFFSET: Record = { mon: 0, tue: 1, wed: 2, thu: 3, fri: 4, sat: 5, sun: 6 }; + +// No stored time-of-day for a meal slot, so each mealType gets a +// conventional wall-clock time and a rough duration for the calendar block. +const MEAL_TIME: Record = { + breakfast: { hour: 8, minute: 0, durationMinutes: 30 }, + lunch: { hour: 12, minute: 30, durationMinutes: 45 }, + dinner: { hour: 19, minute: 0, durationMinutes: 60 }, + snack: { hour: 15, minute: 30, durationMinutes: 15 }, +}; + +export async function GET(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { weekStart } = await params; + + const [y, m, d] = weekStart.split("-").map(Number); + if (!y || !m || !d) return NextResponse.json({ error: "Invalid weekStart" }, { status: 400 }); + const monday = new Date(y, m - 1, d); + + const plan = await db.query.mealPlans.findFirst({ + where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)), + with: { entries: { with: { recipe: true, batchDish: true } } }, + }); + + const events: IcsEvent[] = (plan?.entries ?? []).flatMap((entry) => { + const title = entry.batchDish?.name ?? entry.recipe?.title; + if (!title) return []; + const offset = DAY_OFFSET[entry.day] ?? 0; + const time = MEAL_TIME[entry.mealType] ?? MEAL_TIME["dinner"]!; + const start = new Date(monday); + start.setDate(start.getDate() + offset); + start.setHours(time.hour, time.minute, 0, 0); + + return [{ + uid: `epicure-meal-${entry.id}@epicure`, + title: `${title} (${entry.mealType})`, + description: entry.note ?? undefined, + start, + durationMinutes: time.durationMinutes, + }]; + }); + + const ics = buildIcs(`Epicure meal plan — week of ${weekStart}`, events); + + return new NextResponse(ics, { + headers: { + "Content-Type": "text/calendar; charset=utf-8", + "Content-Disposition": `attachment; filename="meal-plan-${weekStart}.ics"`, + }, + }); +} diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 628b39a..68b6d2d 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -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", diff --git a/apps/web/lib/ics.ts b/apps/web/lib/ics.ts new file mode 100644 index 0000000..2b551b2 --- /dev/null +++ b/apps/web/lib/ics.ts @@ -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"; +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 89f2c6e..8c7d22e 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -830,6 +830,7 @@ "markCooked": "Mark as cooked", "markCookedSuccess": "Marked as cooked", "markCookedFailed": "Failed to mark as cooked", + "exportCalendar": "Export to calendar", "clearDay": "Clear day", "clearWeek": "Clear week", "clearDayConfirmTitle": "Clear {day}?", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 7a6987c..9ecd691 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -818,6 +818,7 @@ "markCooked": "Marquer comme cuisiné", "markCookedSuccess": "Marqué comme cuisiné", "markCookedFailed": "Échec du marquage comme cuisiné", + "exportCalendar": "Exporter vers le calendrier", "clearDay": "Vider la journée", "clearWeek": "Vider la semaine", "clearDayConfirmTitle": "Vider {day} ?", diff --git a/apps/web/package.json b/apps/web/package.json index 186e5b7..ea79e48 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.18.0", + "version": "0.19.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 86cb5f5..ea62b8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.18.0", + "version": "0.19.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",