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:
Arnaud
2026-07-13 22:22:33 +02:00
parent 00ca8b9d68
commit 6513bfa6ee
9 changed files with 151 additions and 4 deletions
+5 -1
View File
@@ -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"`,
},
});
}