feat: batch-cook shopping list (already worked) + leftover expiry reminders

Shopping list add already worked generically for batch-cook recipes —
no code needed there.

New: mark a specific batch-cook dish as "cooked today", track its
fridge expiry (cookingHistory.batchDishId), surface a "Leftovers
expiring soon" widget on the pantry page, and send a daily push+email
reminder via a new /api/internal/cron/leftover-reminders endpoint
(mirrors the weekly-digest cron pattern; doesn't use the social
notifications table, which requires a non-null actor and isn't built
for self-reminders).

Also fixes, from user-reported bugs:
- Recipe cards showed no batch-cook badge/dish-count/prep-time in some
  views — added dishCount + prepMins/cookMins (now generated by the AI
  and persisted) to the card component and /recipes query.
- Batch-cook descriptions occasionally contained raw markdown
  (**bold**) — added explicit "plain prose only" prompt instructions
  and a stripMarkdown() defensive fallback at render time.
- Truncated/cut-off descriptions — the generateObject call had no
  maxOutputTokens set, so long structured responses could get cut off
  mid-field; now capped explicitly at 8000.
- Generate dialogs (batch-cook + the main AI dialog) could show
  buttons unreachable once the progress bar appeared mid-generation —
  restructured so the action row is pinned outside the scrollable
  content area, not affected by content height changes.
- /api/internal/* routes were being redirected to /login by middleware
  before their own CRON_SECRET check ever ran (pre-existing bug,
  affected the weekly-digest cron too) — added to PUBLIC_PATHS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-12 10:03:52 +02:00
parent 646c97128d
commit 002f14ced0
24 changed files with 5070 additions and 47 deletions
+2 -1
View File
@@ -65,5 +65,6 @@ FROM base AS cron
RUN apk add --no-cache curl
COPY cron/crontab /etc/crontabs/root
COPY cron/run-digest.sh /usr/local/bin/run-digest.sh
RUN chmod +x /usr/local/bin/run-digest.sh
COPY cron/run-leftover-reminders.sh /usr/local/bin/run-leftover-reminders.sh
RUN chmod +x /usr/local/bin/run-digest.sh /usr/local/bin/run-leftover-reminders.sh
CMD ["crond", "-f", "-l", "2"]
+29 -2
View File
@@ -1,12 +1,14 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, pantryItems, recipes, eq, or, inArray } from "@epicure/db";
import { db, pantryItems, recipes, cookingHistory, eq, and, or, inArray, isNotNull } from "@epicure/db";
import { asc } from "@epicure/db";
import { PantryManager } from "@/components/meal-plan/pantry-manager";
import { PantryPageHeader } from "@/components/pantry/pantry-page-header";
import { ExpiringSoonSuggestions } from "@/components/pantry/expiring-soon-suggestions";
import { ExpiringLeftovers } from "@/components/pantry/expiring-leftovers";
import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
import { dishExpiresAt, daysUntil, isLeftoverExpiringSoon } from "@/lib/leftover-match";
import { getPublicUrl } from "@/lib/storage";
export const metadata: Metadata = {};
@@ -15,7 +17,7 @@ export default async function PantryPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const [items, candidateRecipes] = await Promise.all([
const [items, candidateRecipes, cookedDishes] = await Promise.all([
db.query.pantryItems.findMany({
where: eq(pantryItems.userId, session.user.id),
orderBy: asc(pantryItems.rawName),
@@ -24,6 +26,13 @@ export default async function PantryPage() {
where: or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"])),
with: { ingredients: true, photos: true },
}),
db.query.cookingHistory.findMany({
where: and(eq(cookingHistory.userId, session.user.id), isNotNull(cookingHistory.batchDishId)),
with: {
batchDish: { columns: { id: true, name: true, fridgeDays: true } },
recipe: { columns: { id: true, title: true } },
},
}),
]);
const mappedItems = items.map((i) => ({
@@ -47,9 +56,27 @@ export default async function PantryPage() {
};
});
const latestByDish = new Map<string, (typeof cookedDishes)[number]>();
for (const log of cookedDishes) {
if (!log.batchDish) continue;
const existing = latestByDish.get(log.batchDish.id);
if (!existing || log.cookedAt > existing.cookedAt) latestByDish.set(log.batchDish.id, log);
}
const leftovers = [...latestByDish.values()]
.filter((log) => log.batchDish && isLeftoverExpiringSoon(log.cookedAt, log.batchDish.fridgeDays))
.map((log) => ({
recipeId: log.recipe.id,
dishName: log.batchDish!.name,
recipeTitle: log.recipe.title,
expiresAt: dishExpiresAt(log.cookedAt, log.batchDish!.fridgeDays).toISOString(),
daysLeft: daysUntil(dishExpiresAt(log.cookedAt, log.batchDish!.fridgeDays)),
}))
.sort((a, b) => a.daysLeft - b.daysLeft);
return (
<div className="space-y-6">
<PantryPageHeader items={mappedItems} />
<ExpiringLeftovers leftovers={leftovers} />
<ExpiringSoonSuggestions suggestions={suggestions} />
<PantryManager key={mappedItems.map((i) => i.id).join(",")} initialItems={mappedItems} />
</div>
+23 -6
View File
@@ -18,8 +18,8 @@ import { ForkRecipeButton } from "@/components/recipe/fork-recipe-button";
import { NutritionPanel } from "@/components/recipe/nutrition-panel";
import { GenerateContentButton } from "@/components/recipe/generate-content-button";
import { auth } from "@/lib/auth/server";
import { db, recipes, ratings, favorites, recipeVariations, recipeNotes, avg } from "@epicure/db";
import { and, eq, or, count, inArray } from "@epicure/db";
import { db, recipes, ratings, favorites, recipeVariations, recipeNotes, cookingHistory, avg } from "@epicure/db";
import { and, eq, or, count, inArray, desc } from "@epicure/db";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { buttonVariants } from "@/components/ui/button";
@@ -32,7 +32,7 @@ import { CookedItReview } from "@/components/social/cooked-it-review";
import { RecipeNotes } from "@/components/recipe/recipe-notes";
import { CommentsSection } from "@/components/social/comments-section";
import { getPublicUrl } from "@/lib/storage";
import { cn } from "@/lib/utils";
import { cn, stripMarkdown } from "@/lib/utils";
import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel";
import { BatchCookSteps } from "@/components/recipe/batch-cook-steps";
import { BatchCookDishes } from "@/components/recipe/batch-cook-dishes";
@@ -62,7 +62,7 @@ export default async function RecipePage({ params }: Params) {
const DIETARY_LABELS = m.recipe.dietary;
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote] = await Promise.all([
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote, dishCookLog] = await Promise.all([
db.query.recipes.findFirst({
where: and(
eq(recipes.id, id),
@@ -87,12 +87,24 @@ export default async function RecipePage({ params }: Params) {
where: and(eq(recipeNotes.recipeId, id), eq(recipeNotes.userId, session.user.id)),
columns: { content: true },
}),
db.query.cookingHistory.findMany({
where: and(eq(cookingHistory.recipeId, id), eq(cookingHistory.userId, session.user.id)),
orderBy: desc(cookingHistory.cookedAt),
columns: { batchDishId: true, cookedAt: true },
}),
]);
if (!recipe) notFound();
const isOwner = recipe.authorId === session.user.id;
const dishCookedAtMap = new Map<string, string>();
for (const log of dishCookLog) {
if (log.batchDishId && !dishCookedAtMap.has(log.batchDishId)) {
dishCookedAtMap.set(log.batchDishId, log.cookedAt.toISOString());
}
}
const avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null;
const ratingCount = ratingData[0]?.total ?? 0;
const isFavorited = !!favoriteData;
@@ -267,7 +279,9 @@ export default async function RecipePage({ params }: Params) {
)}
{recipe.description && (
<p className="text-muted-foreground leading-relaxed">{recipe.description}</p>
<p className="text-muted-foreground leading-relaxed">
{recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description}
</p>
)}
{recipe.sourceUrl && (
@@ -411,7 +425,10 @@ export default async function RecipePage({ params }: Params) {
{recipe.isBatchCook && recipe.batchDishes.length > 0 && (
<>
<Separator />
<BatchCookDishes dishes={recipe.batchDishes} />
<BatchCookDishes
recipeId={id}
dishes={recipe.batchDishes.map((d) => ({ ...d, cookedAt: dishCookedAtMap.get(d.id) ?? null }))}
/>
</>
)}
+2 -2
View File
@@ -69,7 +69,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
db.query.recipes.findMany({
where,
orderBy: SORT_MAP[sortKey],
with: { photos: true },
with: { photos: true, batchDishes: { columns: { id: true } } },
limit: PAGE_SIZE,
offset,
}),
@@ -87,7 +87,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
.where(and(eq(favorites.userId, session.user.id), inArray(favorites.recipeId, recipeIds)))
: [];
const favoritedIds = new Set(favoritedRows.map((r) => r.recipeId));
const recipesWithFavorites = userRecipes.map((r) => ({ ...r, isFavorited: favoritedIds.has(r.id) }));
const recipesWithFavorites = userRecipes.map((r) => ({ ...r, isFavorited: favoritedIds.has(r.id), dishCount: r.batchDishes.length }));
const pageHref = (p: number) => {
const params = new URLSearchParams();
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { db, cookingHistory, eq, and, isNotNull, isNull } from "@epicure/db";
import { sendEmail, notificationEmailHtml } from "@/lib/email";
import { sendPushNotification } from "@/lib/push";
import { isLeftoverExpiringSoon } from "@/lib/leftover-match";
import { getMessages, formatMessage } from "@/lib/i18n/server";
// Internal cron endpoint — triggered daily by a cron container (see
// compose.prod.yml / cron/crontab). Not part of the public API surface;
// protected by a shared secret rather than user auth.
//
// For every cooking_history row tied to a batch-cook dish, checks whether it
// expires soon (see lib/leftover-match.ts) and hasn't already been reminded
// about, then sends one push + email and marks it reminded so it never fires
// twice for the same cooked dish.
function isAuthorized(req: NextRequest): boolean {
const secret = process.env["CRON_SECRET"];
if (!secret) return false;
const header = req.headers.get("authorization");
if (!header?.startsWith("Bearer ")) return false;
const provided = header.slice("Bearer ".length);
const a = Buffer.from(provided);
const b = Buffer.from(secret);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
export async function POST(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const candidates = await db.query.cookingHistory.findMany({
where: and(isNotNull(cookingHistory.batchDishId), isNull(cookingHistory.expiryReminderSentAt)),
with: {
batchDish: { columns: { id: true, name: true, fridgeDays: true } },
recipe: { columns: { id: true, title: true } },
user: { columns: { id: true, email: true, locale: true } },
},
});
let sent = 0;
for (const log of candidates) {
if (!log.batchDish || !log.user) continue;
if (!isLeftoverExpiringSoon(log.cookedAt, log.batchDish.fridgeDays)) continue;
const messages = getMessages(log.user.locale);
const template = messages.notifications.detail.leftoverExpiring;
const title = messages.notifications.pushTitle.leftoverExpiring;
const body = formatMessage(template, { dish: log.batchDish.name, title: log.recipe.title });
const url = `/recipes/${log.recipe.id}`;
await Promise.all([
sendPushNotification(log.user.id, { title, body, url }).catch((err) => {
console.error("[leftover-reminders] push failed", err);
}),
log.user.email
? sendEmail({
to: log.user.email,
subject: title,
html: notificationEmailHtml(title, body, `${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"}${url}`),
}).catch((err) => {
console.error("[leftover-reminders] email failed", err);
})
: Promise.resolve(),
]);
await db.update(cookingHistory)
.set({ expiryReminderSentAt: new Date() })
.where(eq(cookingHistory.id, log.id));
sent++;
}
return NextResponse.json({ ok: true, checked: candidates.length, sent });
}
@@ -71,6 +71,8 @@ export async function POST(req: NextRequest) {
aiGenerated: true,
isBatchCook: true,
language: locale,
prepMins: plan.prepMins,
cookMins: plan.cookMins,
});
if (plan.ingredients.length > 0) {
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, cookingHistory, pantryItems, recipeIngredients, eq, and } from "@epicure/db";
import { db, recipes, cookingHistory, pantryItems, recipeIngredients, recipeBatchDishes, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
@@ -9,6 +9,7 @@ const Schema = z.object({
servings: z.number().int().min(1).max(1000).optional(),
notes: z.string().max(2000).optional(),
deductFromPantry: z.boolean().default(true),
batchDishId: z.string().optional(),
});
export async function POST(req: NextRequest, { params }: Params) {
@@ -26,16 +27,24 @@ export async function POST(req: NextRequest, { params }: Params) {
const parsed = Schema.safeParse(body);
const data = parsed.success ? parsed.data : { deductFromPantry: true };
if (data.batchDishId) {
const dish = await db.query.recipeBatchDishes.findFirst({
where: and(eq(recipeBatchDishes.id, data.batchDishId), eq(recipeBatchDishes.recipeId, id)),
});
if (!dish) return NextResponse.json({ error: "Dish not found" }, { status: 404 });
}
await db.insert(cookingHistory).values({
id: crypto.randomUUID(),
userId,
recipeId: id,
batchDishId: data.batchDishId,
servings: data.servings,
notes: data.notes,
cookedAt: new Date(),
});
if (data.deductFromPantry) {
if (data.deductFromPantry && !data.batchDishId) {
const ings = await db.query.recipeIngredients.findMany({
where: eq(recipeIngredients.recipeId, id),
});
@@ -0,0 +1,51 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { AlertTriangle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
type Leftover = {
recipeId: string;
dishName: string;
recipeTitle: string;
expiresAt: string;
daysLeft: number;
};
export function ExpiringLeftovers({ leftovers }: { leftovers: Leftover[] }) {
const t = useTranslations("pantry");
if (leftovers.length === 0) return null;
return (
<div className="rounded-xl border p-4 space-y-3">
<h2 className="font-semibold flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-orange-500" />
{t("expiringLeftoversTitle")}
</h2>
<div className="space-y-2">
{leftovers.map((l) => (
<Link
key={`${l.recipeId}-${l.dishName}`}
href={`/recipes/${l.recipeId}`}
className="flex items-center justify-between gap-3 rounded-lg border p-2.5 hover:bg-accent transition-colors"
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{l.dishName}</p>
<p className="text-xs text-muted-foreground truncate">{l.recipeTitle}</p>
</div>
<Badge
variant="outline"
className={l.daysLeft <= 0 ? "text-destructive border-destructive shrink-0" : "text-orange-500 border-orange-500 shrink-0"}
>
{l.daysLeft <= 0
? t("expiringToday")
: t("expiringInDays", { days: l.daysLeft })}
</Badge>
</Link>
))}
</div>
</div>
);
}
@@ -216,7 +216,8 @@ export function AiGenerateDialog({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
<DialogContent className="max-w-xl max-h-[85vh] flex flex-col gap-0 p-0">
<div className="overflow-y-auto p-4 space-y-4 min-h-0">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
@@ -370,8 +371,9 @@ export function AiGenerateDialog({
label={busy ? (tab === "photo" ? t("analyzePhoto") : tab === "batch" ? tBatch("generating") : t("generating")) : undefined}
/>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 justify-end pt-1">
<div className="flex gap-2 justify-end p-4 border-t shrink-0">
<Button variant="ghost" onClick={handleClose} disabled={busy}>
{tCommon("cancel")}
</Button>
@@ -1,7 +1,12 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Snowflake, Refrigerator, ChefHat } from "lucide-react";
import { toast } from "sonner";
import { Snowflake, Refrigerator, ChefHat, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n/provider";
import { stripMarkdown } from "@/lib/utils";
type Dish = {
id: string;
@@ -11,10 +16,40 @@ type Dish = {
freezerFriendly: boolean;
freezerNote: string | null;
dayOfInstructions: string;
cookedAt: string | null;
};
export function BatchCookDishes({ dishes }: { dishes: Dish[] }) {
function expiresOn(cookedAt: string, fridgeDays: number): Date {
const d = new Date(cookedAt);
d.setDate(d.getDate() + fridgeDays);
return d;
}
export function BatchCookDishes({ recipeId, dishes: initialDishes }: { recipeId: string; dishes: Dish[] }) {
const t = useTranslations("recipe");
const { locale } = useLocale();
const [dishes, setDishes] = useState(initialDishes);
const [markingId, setMarkingId] = useState<string | null>(null);
async function markCooked(dishId: string) {
setMarkingId(dishId);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/cooked`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ batchDishId: dishId, deductFromPantry: false }),
});
if (!res.ok) {
toast.error(t("batchCookMarkFailed"));
return;
}
const now = new Date().toISOString();
setDishes((prev) => prev.map((d) => (d.id === dishId ? { ...d, cookedAt: now } : d)));
toast.success(t("batchCookMarkSuccess"));
} finally {
setMarkingId(null);
}
}
return (
<div className="space-y-4">
@@ -24,7 +59,7 @@ export function BatchCookDishes({ dishes }: { dishes: Dish[] }) {
<div key={dish.id} className="rounded-xl border p-4 space-y-3">
<div>
<h3 className="font-semibold">{dish.name}</h3>
{dish.description && <p className="text-sm text-muted-foreground mt-0.5">{dish.description}</p>}
{dish.description && <p className="text-sm text-muted-foreground mt-0.5">{stripMarkdown(dish.description)}</p>}
</div>
<div className="flex flex-wrap gap-2 text-xs">
@@ -47,9 +82,29 @@ export function BatchCookDishes({ dishes }: { dishes: Dish[] }) {
<ChefHat className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">{t("batchCookDayOf")}</p>
<p className="text-sm text-muted-foreground mt-0.5">{dish.dayOfInstructions}</p>
<p className="text-sm text-muted-foreground mt-0.5">{stripMarkdown(dish.dayOfInstructions)}</p>
</div>
</div>
{dish.cookedAt ? (
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Check className="h-3.5 w-3.5 text-primary" />
{t("batchCookExpiresOn", {
date: expiresOn(dish.cookedAt, dish.fridgeDays).toLocaleDateString(locale, { month: "short", day: "numeric" }),
})}
</p>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
disabled={markingId === dish.id}
onClick={() => { void markCooked(dish.id); }}
>
<ChefHat className="h-3.5 w-3.5" />
{t("batchCookMarkCooked")}
</Button>
)}
</div>
))}
</div>
@@ -73,7 +73,8 @@ export function BatchCookGenerateDialog({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[85vh] overflow-y-auto">
<DialogContent className="max-w-md max-h-[85vh] flex flex-col gap-0 p-0">
<div className="overflow-y-auto p-4 space-y-4 min-h-0">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ChefHat className="h-5 w-5 text-primary" />
@@ -87,8 +88,9 @@ export function BatchCookGenerateDialog({
<div className="min-h-[26px]">
<FakeProgressBar active={busy} durationMs={20000} label={busy ? t("generating") : undefined} />
</div>
</div>
<div className="flex gap-2 justify-end pt-1">
<div className="flex gap-2 justify-end p-4 border-t shrink-0">
<Button variant="ghost" onClick={handleClose} disabled={busy}>
{tCommon("cancel")}
</Button>
+17 -4
View File
@@ -28,9 +28,9 @@ import { toast } from "sonner";
import { getPublicUrl } from "@/lib/storage";
import { useLocale } from "@/lib/i18n/provider";
import { Badge } from "@/components/ui/badge";
import { Clock, Users } from "lucide-react";
import { Clock, Users, Utensils } from "lucide-react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { cn, stripMarkdown } from "@/lib/utils";
type Recipe = {
id: string;
@@ -46,6 +46,7 @@ type Recipe = {
photos?: Array<{ storageKey: string; isCover: boolean }>;
isFavorited?: boolean;
isBatchCook?: boolean;
dishCount?: number;
};
/** Stops the click from bubbling to the card's own Link/select handler. */
@@ -98,6 +99,7 @@ function RecipeThumb({ recipe, selectMode, selected, className }: { recipe: Reci
function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected: boolean; selectMode: boolean }) {
const t = useTranslations("recipe");
const tBatch = useTranslations("batchCooking");
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
@@ -122,7 +124,9 @@ function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected:
{recipe.isBatchCook && <ChefHat className="h-3.5 w-3.5 text-primary shrink-0" />}
</div>
{recipe.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{recipe.description}</p>
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
{recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description}
</p>
)}
{recipe.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
@@ -139,7 +143,11 @@ function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected:
</div>
<div className="px-3 pb-3 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2.5">
{recipe.isBatchCook && recipe.dishCount ? (
<span className="flex items-center gap-1"><Utensils className="h-3 w-3" />{tBatch("dishCount", { count: recipe.dishCount })}</span>
) : (
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{recipe.baseServings}</span>
)}
{totalMins > 0 && <span className="flex items-center gap-1"><Clock className="h-3 w-3" />{t("total", { mins: totalMins })}</span>}
{recipe.difficulty && (
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">{t(`difficulty.${recipe.difficulty}`)}</Badge>
@@ -160,6 +168,7 @@ function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected:
function ListRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: boolean; selectMode: boolean }) {
const t = useTranslations("recipe");
const tBatch = useTranslations("batchCooking");
const { locale } = useLocale();
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
@@ -188,9 +197,13 @@ function ListRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: b
<VisibilityIcon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</div>
{recipe.description && <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{recipe.description}</p>}
{recipe.description && <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description}</p>}
<div className="flex items-center gap-2.5 text-xs text-muted-foreground mt-auto flex-wrap">
{recipe.isBatchCook && recipe.dishCount ? (
<span className="flex items-center gap-1"><Utensils className="h-3 w-3" />{tBatch("dishCount", { count: recipe.dishCount })}</span>
) : (
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{recipe.baseServings}</span>
)}
{totalMins > 0 && <span className="flex items-center gap-1"><Clock className="h-3 w-3" />{t("total", { mins: totalMins })}</span>}
{recipe.difficulty && <Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">{t(`difficulty.${recipe.difficulty}`)}</Badge>}
{recipe.tags.slice(0, 2).map((tag) => (
@@ -4,14 +4,16 @@ import { resolveModel, type AiConfig } from "../factory";
const BatchCookSchema = z.object({
title: z.string().max(150),
description: z.string().max(300),
description: z.string().max(300).describe("Plain prose only — no markdown formatting (no **bold**, no bullet points, no headers)."),
prepMins: z.number().int().min(0).max(300).describe("Total hands-on prep time for the whole session, in minutes."),
cookMins: z.number().int().min(0).max(300).describe("Total passive cook/bake/simmer time for the whole session, in minutes."),
dishes: z.array(z.object({
name: z.string().max(100),
description: z.string().max(200),
description: z.string().max(200).describe("Plain prose only — no markdown formatting."),
fridgeDays: z.number().int().min(1).max(7),
freezerFriendly: z.boolean(),
freezerNote: z.string().max(150).optional(),
dayOfInstructions: z.string().max(300),
dayOfInstructions: z.string().max(300).describe("Plain prose only — no markdown formatting."),
})).min(1).max(6),
ingredients: z.array(z.object({
rawName: z.string().describe("Ingredient name only — never include the quantity or unit here."),
@@ -72,7 +74,11 @@ Dishes must be genuinely distinct — different techniques and flavor profiles (
Every dish needs: fridgeDays, freezerFriendly, and a freezerNote if applicable (duration + thawing tip). dayOfInstructions must name the exact reheat method and time, AND a concrete fresh finishing touch (stir in cream, cook fresh grain, add a fresh side, sear a protein, grate cheese) — never generic ("reheat and enjoy" is unacceptable).
For ingredients: quantity is a number only, unit is a separate string. Never combine them. Respond in ${lang}.`;
For ingredients: quantity is a number only, unit is a separate string. Never combine them.
Estimate prepMins (total hands-on active work across the whole session) and cookMins (total passive oven/stovetop/simmer time) for the whole session, in minutes.
Write every description and instruction as plain prose — never use markdown formatting (no **bold**, no bullet lists, no headers, no asterisks). Keep every field within its stated length limit; a shorter, complete sentence is always better than a longer one that gets cut off. Respond in ${lang}.`;
const systemPromptWithContext = systemPrompt + (config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : "");
@@ -80,6 +86,7 @@ For ingredients: quantity is a number only, unit is a separate string. Never com
model,
schema: BatchCookSchema,
system: systemPromptWithContext,
maxOutputTokens: 8000,
prompt: [
`Generate a batch-cooking session covering ${mealParts.join(" and ") || `${totalDishes} meals`}.`,
`Design exactly ${totalDishes} distinct dishes, each for ${servings} servings.`,
+17
View File
@@ -0,0 +1,17 @@
export const LEFTOVER_EXPIRING_WITHIN_DAYS = 2;
export function daysUntil(expiresAt: Date): number {
const ms = expiresAt.getTime() - Date.now();
return Math.ceil(ms / (1000 * 60 * 60 * 24));
}
export function dishExpiresAt(cookedAt: Date, fridgeDays: number): Date {
const d = new Date(cookedAt);
d.setDate(d.getDate() + fridgeDays);
return d;
}
export function isLeftoverExpiringSoon(cookedAt: Date, fridgeDays: number): boolean {
const days = daysUntil(dishExpiresAt(cookedAt, fridgeDays));
return days >= 0 && days <= LEFTOVER_EXPIRING_WITHIN_DAYS;
}
+10
View File
@@ -4,3 +4,13 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** Strips common markdown syntax for display in plain-text contexts (AI output occasionally ignores "no markdown" instructions). */
export function stripMarkdown(text: string): string {
return text
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/\*(.+?)\*/g, "$1")
.replace(/`(.+?)`/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^[-*+]\s+/gm, "");
}
+11 -2
View File
@@ -40,7 +40,8 @@
"mention": "{name} mentioned you in a comment",
"detail": {
"comment": "{name} commented on your recipe \"{title}\"",
"rating": "{name} rated your recipe \"{title}\" {stars} stars"
"rating": "{name} rated your recipe \"{title}\" {stars} stars",
"leftoverExpiring": "Your \"{dish}\" from \"{title}\" is about to expire — eat it soon!"
},
"pushTitle": {
"follow": "New follower",
@@ -48,7 +49,8 @@
"reply": "New reply",
"reaction": "New reaction",
"rating": "New rating",
"mention": "You were mentioned"
"mention": "You were mentioned",
"leftoverExpiring": "Leftovers expiring soon"
}
},
"recipe": {
@@ -68,6 +70,10 @@
"batchCookFridgeDays": "Fridge: {days}d",
"batchCookFreezerFriendly": "Freezer-friendly",
"batchCookDayOf": "On the day",
"batchCookMarkCooked": "Mark as cooked today",
"batchCookMarkSuccess": "Marked as cooked",
"batchCookMarkFailed": "Failed to mark as cooked",
"batchCookExpiresOn": "Cooked — good until {date}",
"servings": "{count} servings",
"prep": "{mins}m prep",
"cook": "{mins}m cook",
@@ -770,6 +776,9 @@
"canCook": "What can I cook?",
"expiringSoonSuggestionsTitle": "Use it up soon",
"seeAll": "See all",
"expiringLeftoversTitle": "Leftovers expiring soon",
"expiringToday": "Eat today",
"expiringInDays": "{days}d left",
"itemNamePlaceholder": "Item name",
"qtyPlaceholder": "Qty",
"unitPlaceholder": "Unit",
+11 -2
View File
@@ -40,7 +40,8 @@
"mention": "{name} vous a mentionné dans un commentaire",
"detail": {
"comment": "{name} a commenté votre recette « {title} »",
"rating": "{name} a noté votre recette « {title} » {stars} étoiles"
"rating": "{name} a noté votre recette « {title} » {stars} étoiles",
"leftoverExpiring": "Votre « {dish} » de « {title} » arrive à expiration — à manger vite !"
},
"pushTitle": {
"follow": "Nouvel abonné",
@@ -48,7 +49,8 @@
"reply": "Nouvelle réponse",
"reaction": "Nouvelle réaction",
"rating": "Nouvelle note",
"mention": "Vous avez été mentionné"
"mention": "Vous avez été mentionné",
"leftoverExpiring": "Restes bientôt périmés"
}
},
"recipe": {
@@ -68,6 +70,10 @@
"batchCookFridgeDays": "Frigo : {days}j",
"batchCookFreezerFriendly": "Se congèle",
"batchCookDayOf": "Le jour J",
"batchCookMarkCooked": "Marquer comme cuisiné aujourd'hui",
"batchCookMarkSuccess": "Marqué comme cuisiné",
"batchCookMarkFailed": "Échec du marquage",
"batchCookExpiresOn": "Cuisiné — bon jusqu'au {date}",
"servings": "{count} portions",
"prep": "{mins}m prép.",
"cook": "{mins}m cuisson",
@@ -758,6 +764,9 @@
"canCook": "Que puis-je cuisiner ?",
"expiringSoonSuggestionsTitle": "À utiliser bientôt",
"seeAll": "Tout voir",
"expiringLeftoversTitle": "Restes bientôt périmés",
"expiringToday": "À manger aujourd'hui",
"expiringInDays": "{days}j restants",
"itemNamePlaceholder": "Nom de l'article",
"qtyPlaceholder": "Qté",
"unitPlaceholder": "Unité",
+1 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/"];
const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/", "/api/internal/"];
const ADMIN_PATHS = ["/admin"];
export async function proxy(request: NextRequest) {
+3
View File
@@ -1,2 +1,5 @@
# Weekly digest — every Monday at 08:00 UTC.
0 8 * * 1 /usr/local/bin/run-digest.sh >> /proc/1/fd/1 2>> /proc/1/fd/2
# Leftover-expiry reminders — daily at 09:00 UTC.
0 9 * * * /usr/local/bin/run-leftover-reminders.sh >> /proc/1/fd/1 2>> /proc/1/fd/2
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Triggers the leftover-expiry reminder send on the `web` service. Run on a
# schedule by busybox crond (see cron/crontab). Fails loudly (non-zero exit,
# stderr) but crond just tries again tomorrow — no retry loop here on purpose,
# keep this script trivial.
set -eu
: "${WEB_INTERNAL_URL:=http://web:3000}"
if [ -z "${CRON_SECRET:-}" ]; then
echo "run-leftover-reminders: CRON_SECRET is not set, refusing to call the endpoint" >&2
exit 1
fi
curl -fsS -X POST \
-H "Authorization: Bearer ${CRON_SECRET}" \
"${WEB_INTERNAL_URL}/api/internal/cron/leftover-reminders"
@@ -0,0 +1,4 @@
ALTER TABLE "cooking_history" ADD COLUMN "batch_dish_id" text;--> statement-breakpoint
ALTER TABLE "cooking_history" ADD COLUMN "expiry_reminder_sent_at" timestamp;--> statement-breakpoint
ALTER TABLE "cooking_history" ADD CONSTRAINT "cooking_history_batch_dish_id_recipe_batch_dishes_id_fk" FOREIGN KEY ("batch_dish_id") REFERENCES "public"."recipe_batch_dishes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "cooking_history_batch_dish_idx" ON "cooking_history" USING btree ("batch_dish_id");
File diff suppressed because it is too large Load Diff
@@ -218,6 +218,13 @@
"when": 1783814978348,
"tag": "0030_kind_lightspeed",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1783841772890,
"tag": "0031_brave_arclight",
"breakpoints": true
}
]
}
+5 -1
View File
@@ -11,7 +11,7 @@ import {
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./users";
import { recipes } from "./recipes";
import { recipes, recipeBatchDishes } from "./recipes";
export const notificationTypeEnum = pgEnum("notification_type", [
"follow",
@@ -87,11 +87,14 @@ export const cookingHistory = pgTable("cooking_history", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
batchDishId: text("batch_dish_id").references(() => recipeBatchDishes.id, { onDelete: "cascade" }),
cookedAt: timestamp("cooked_at").notNull().defaultNow(),
servings: integer("servings"),
notes: text("notes"),
expiryReminderSentAt: timestamp("expiry_reminder_sent_at"),
}, (t) => [
index("cooking_history_user_idx").on(t.userId),
index("cooking_history_batch_dish_idx").on(t.batchDishId),
]);
export const notifications = pgTable("notifications", {
@@ -111,6 +114,7 @@ export const notifications = pgTable("notifications", {
export const cookingHistoryRelations = relations(cookingHistory, ({ one }) => ({
user: one(users, { fields: [cookingHistory.userId], references: [users.id] }),
recipe: one(recipes, { fields: [cookingHistory.recipeId], references: [recipes.id] }),
batchDish: one(recipeBatchDishes, { fields: [cookingHistory.batchDishId], references: [recipeBatchDishes.id] }),
}));
export const notificationsRelations = relations(notifications, ({ one }) => ({