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:
@@ -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
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
<a href={`/api/v1/meal-plans/${weekStart}/export/ics`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
{msgs.mealPlan.exportCalendar}
|
||||
</a>
|
||||
<Link href={`/meal-plan?week=${prevWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
@@ -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<string, number> = { 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<string, { hour: number; minute: number; durationMinutes: number }> = {
|
||||
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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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}?",
|
||||
|
||||
@@ -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} ?",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Reference in New Issue
Block a user