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",