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:
+2
-1
@@ -65,5 +65,6 @@ FROM base AS cron
|
|||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl
|
||||||
COPY cron/crontab /etc/crontabs/root
|
COPY cron/crontab /etc/crontabs/root
|
||||||
COPY cron/run-digest.sh /usr/local/bin/run-digest.sh
|
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"]
|
CMD ["crond", "-f", "-l", "2"]
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth/server";
|
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 { asc } from "@epicure/db";
|
||||||
import { PantryManager } from "@/components/meal-plan/pantry-manager";
|
import { PantryManager } from "@/components/meal-plan/pantry-manager";
|
||||||
import { PantryPageHeader } from "@/components/pantry/pantry-page-header";
|
import { PantryPageHeader } from "@/components/pantry/pantry-page-header";
|
||||||
import { ExpiringSoonSuggestions } from "@/components/pantry/expiring-soon-suggestions";
|
import { ExpiringSoonSuggestions } from "@/components/pantry/expiring-soon-suggestions";
|
||||||
|
import { ExpiringLeftovers } from "@/components/pantry/expiring-leftovers";
|
||||||
import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
|
import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
|
||||||
|
import { dishExpiresAt, daysUntil, isLeftoverExpiringSoon } from "@/lib/leftover-match";
|
||||||
import { getPublicUrl } from "@/lib/storage";
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
@@ -15,7 +17,7 @@ export default async function PantryPage() {
|
|||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
|
|
||||||
const [items, candidateRecipes] = await Promise.all([
|
const [items, candidateRecipes, cookedDishes] = await Promise.all([
|
||||||
db.query.pantryItems.findMany({
|
db.query.pantryItems.findMany({
|
||||||
where: eq(pantryItems.userId, session.user.id),
|
where: eq(pantryItems.userId, session.user.id),
|
||||||
orderBy: asc(pantryItems.rawName),
|
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"])),
|
where: or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"])),
|
||||||
with: { ingredients: true, photos: true },
|
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) => ({
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PantryPageHeader items={mappedItems} />
|
<PantryPageHeader items={mappedItems} />
|
||||||
|
<ExpiringLeftovers leftovers={leftovers} />
|
||||||
<ExpiringSoonSuggestions suggestions={suggestions} />
|
<ExpiringSoonSuggestions suggestions={suggestions} />
|
||||||
<PantryManager key={mappedItems.map((i) => i.id).join(",")} initialItems={mappedItems} />
|
<PantryManager key={mappedItems.map((i) => i.id).join(",")} initialItems={mappedItems} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import { ForkRecipeButton } from "@/components/recipe/fork-recipe-button";
|
|||||||
import { NutritionPanel } from "@/components/recipe/nutrition-panel";
|
import { NutritionPanel } from "@/components/recipe/nutrition-panel";
|
||||||
import { GenerateContentButton } from "@/components/recipe/generate-content-button";
|
import { GenerateContentButton } from "@/components/recipe/generate-content-button";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, recipes, ratings, favorites, recipeVariations, recipeNotes, avg } from "@epicure/db";
|
import { db, recipes, ratings, favorites, recipeVariations, recipeNotes, cookingHistory, avg } from "@epicure/db";
|
||||||
import { and, eq, or, count, inArray } from "@epicure/db";
|
import { and, eq, or, count, inArray, desc } from "@epicure/db";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
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 { RecipeNotes } from "@/components/recipe/recipe-notes";
|
||||||
import { CommentsSection } from "@/components/social/comments-section";
|
import { CommentsSection } from "@/components/social/comments-section";
|
||||||
import { getPublicUrl } from "@/lib/storage";
|
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 { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel";
|
||||||
import { BatchCookSteps } from "@/components/recipe/batch-cook-steps";
|
import { BatchCookSteps } from "@/components/recipe/batch-cook-steps";
|
||||||
import { BatchCookDishes } from "@/components/recipe/batch-cook-dishes";
|
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 DIETARY_LABELS = m.recipe.dietary;
|
||||||
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
|
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({
|
db.query.recipes.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(recipes.id, id),
|
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)),
|
where: and(eq(recipeNotes.recipeId, id), eq(recipeNotes.userId, session.user.id)),
|
||||||
columns: { content: true },
|
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();
|
if (!recipe) notFound();
|
||||||
|
|
||||||
const isOwner = recipe.authorId === session.user.id;
|
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 avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null;
|
||||||
const ratingCount = ratingData[0]?.total ?? 0;
|
const ratingCount = ratingData[0]?.total ?? 0;
|
||||||
const isFavorited = !!favoriteData;
|
const isFavorited = !!favoriteData;
|
||||||
@@ -267,7 +279,9 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{recipe.description && (
|
{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 && (
|
{recipe.sourceUrl && (
|
||||||
@@ -411,7 +425,10 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
{recipe.isBatchCook && recipe.batchDishes.length > 0 && (
|
{recipe.isBatchCook && recipe.batchDishes.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<BatchCookDishes dishes={recipe.batchDishes} />
|
<BatchCookDishes
|
||||||
|
recipeId={id}
|
||||||
|
dishes={recipe.batchDishes.map((d) => ({ ...d, cookedAt: dishCookedAtMap.get(d.id) ?? null }))}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
|||||||
db.query.recipes.findMany({
|
db.query.recipes.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: SORT_MAP[sortKey],
|
orderBy: SORT_MAP[sortKey],
|
||||||
with: { photos: true },
|
with: { photos: true, batchDishes: { columns: { id: true } } },
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset,
|
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)))
|
.where(and(eq(favorites.userId, session.user.id), inArray(favorites.recipeId, recipeIds)))
|
||||||
: [];
|
: [];
|
||||||
const favoritedIds = new Set(favoritedRows.map((r) => r.recipeId));
|
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 pageHref = (p: number) => {
|
||||||
const params = new URLSearchParams();
|
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,
|
aiGenerated: true,
|
||||||
isBatchCook: true,
|
isBatchCook: true,
|
||||||
language: locale,
|
language: locale,
|
||||||
|
prepMins: plan.prepMins,
|
||||||
|
cookMins: plan.cookMins,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (plan.ingredients.length > 0) {
|
if (plan.ingredients.length > 0) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
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";
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
@@ -9,6 +9,7 @@ const Schema = z.object({
|
|||||||
servings: z.number().int().min(1).max(1000).optional(),
|
servings: z.number().int().min(1).max(1000).optional(),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
deductFromPantry: z.boolean().default(true),
|
deductFromPantry: z.boolean().default(true),
|
||||||
|
batchDishId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: Params) {
|
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 parsed = Schema.safeParse(body);
|
||||||
const data = parsed.success ? parsed.data : { deductFromPantry: true };
|
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({
|
await db.insert(cookingHistory).values({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
recipeId: id,
|
recipeId: id,
|
||||||
|
batchDishId: data.batchDishId,
|
||||||
servings: data.servings,
|
servings: data.servings,
|
||||||
notes: data.notes,
|
notes: data.notes,
|
||||||
cookedAt: new Date(),
|
cookedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.deductFromPantry) {
|
if (data.deductFromPantry && !data.batchDishId) {
|
||||||
const ings = await db.query.recipeIngredients.findMany({
|
const ings = await db.query.recipeIngredients.findMany({
|
||||||
where: eq(recipeIngredients.recipeId, id),
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Sparkles className="h-5 w-5 text-primary" />
|
<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}
|
label={busy ? (tab === "photo" ? t("analyzePhoto") : tab === "batch" ? tBatch("generating") : t("generating")) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Actions */}
|
{/* 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}>
|
<Button variant="ghost" onClick={handleClose} disabled={busy}>
|
||||||
{tCommon("cancel")}
|
{tCommon("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 = {
|
type Dish = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,10 +16,40 @@ type Dish = {
|
|||||||
freezerFriendly: boolean;
|
freezerFriendly: boolean;
|
||||||
freezerNote: string | null;
|
freezerNote: string | null;
|
||||||
dayOfInstructions: string;
|
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 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<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 key={dish.id} className="rounded-xl border p-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">{dish.name}</h3>
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
<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" />
|
<ChefHat className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium">{t("batchCookDayOf")}</p>
|
<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>
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,22 +73,24 @@ export function BatchCookGenerateDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<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">
|
||||||
<DialogHeader>
|
<div className="overflow-y-auto p-4 space-y-4 min-h-0">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogHeader>
|
||||||
<ChefHat className="h-5 w-5 text-primary" />
|
<DialogTitle className="flex items-center gap-2">
|
||||||
{t("wizardTitle")}
|
<ChefHat className="h-5 w-5 text-primary" />
|
||||||
</DialogTitle>
|
{t("wizardTitle")}
|
||||||
<DialogDescription>{t("wizardDescription")}</DialogDescription>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
<DialogDescription>{t("wizardDescription")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<BatchCookFields state={fields} onChange={setFields} disabled={busy} />
|
<BatchCookFields state={fields} onChange={setFields} disabled={busy} />
|
||||||
|
|
||||||
<div className="min-h-[26px]">
|
<div className="min-h-[26px]">
|
||||||
<FakeProgressBar active={busy} durationMs={20000} label={busy ? t("generating") : undefined} />
|
<FakeProgressBar active={busy} durationMs={20000} label={busy ? t("generating") : undefined} />
|
||||||
|
</div>
|
||||||
</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}>
|
<Button variant="ghost" onClick={handleClose} disabled={busy}>
|
||||||
{tCommon("cancel")}
|
{tCommon("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ import { toast } from "sonner";
|
|||||||
import { getPublicUrl } from "@/lib/storage";
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
import { useLocale } from "@/lib/i18n/provider";
|
import { useLocale } from "@/lib/i18n/provider";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 Link from "next/link";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, stripMarkdown } from "@/lib/utils";
|
||||||
|
|
||||||
type Recipe = {
|
type Recipe = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -46,6 +46,7 @@ type Recipe = {
|
|||||||
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
||||||
isFavorited?: boolean;
|
isFavorited?: boolean;
|
||||||
isBatchCook?: boolean;
|
isBatchCook?: boolean;
|
||||||
|
dishCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Stops the click from bubbling to the card's own Link/select handler. */
|
/** 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 }) {
|
function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected: boolean; selectMode: boolean }) {
|
||||||
const t = useTranslations("recipe");
|
const t = useTranslations("recipe");
|
||||||
|
const tBatch = useTranslations("batchCooking");
|
||||||
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||||
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
|
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" />}
|
{recipe.isBatchCook && <ChefHat className="h-3.5 w-3.5 text-primary shrink-0" />}
|
||||||
</div>
|
</div>
|
||||||
{recipe.description && (
|
{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 && (
|
{recipe.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
@@ -139,7 +143,11 @@ function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected:
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-3 pb-3 flex items-center justify-between text-xs text-muted-foreground">
|
<div className="px-3 pb-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{recipe.baseServings}</span>
|
{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>}
|
{totalMins > 0 && <span className="flex items-center gap-1"><Clock className="h-3 w-3" />{t("total", { mins: totalMins })}</span>}
|
||||||
{recipe.difficulty && (
|
{recipe.difficulty && (
|
||||||
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">{t(`difficulty.${recipe.difficulty}`)}</Badge>
|
<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 }) {
|
function ListRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: boolean; selectMode: boolean }) {
|
||||||
const t = useTranslations("recipe");
|
const t = useTranslations("recipe");
|
||||||
|
const tBatch = useTranslations("batchCooking");
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||||
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
|
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" />
|
<VisibilityIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex items-center gap-2.5 text-xs text-muted-foreground mt-auto flex-wrap">
|
||||||
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{recipe.baseServings}</span>
|
{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>}
|
{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.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) => (
|
{recipe.tags.slice(0, 2).map((tag) => (
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ import { resolveModel, type AiConfig } from "../factory";
|
|||||||
|
|
||||||
const BatchCookSchema = z.object({
|
const BatchCookSchema = z.object({
|
||||||
title: z.string().max(150),
|
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({
|
dishes: z.array(z.object({
|
||||||
name: z.string().max(100),
|
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),
|
fridgeDays: z.number().int().min(1).max(7),
|
||||||
freezerFriendly: z.boolean(),
|
freezerFriendly: z.boolean(),
|
||||||
freezerNote: z.string().max(150).optional(),
|
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),
|
})).min(1).max(6),
|
||||||
ingredients: z.array(z.object({
|
ingredients: z.array(z.object({
|
||||||
rawName: z.string().describe("Ingredient name only — never include the quantity or unit here."),
|
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).
|
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}` : "");
|
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,
|
model,
|
||||||
schema: BatchCookSchema,
|
schema: BatchCookSchema,
|
||||||
system: systemPromptWithContext,
|
system: systemPromptWithContext,
|
||||||
|
maxOutputTokens: 8000,
|
||||||
prompt: [
|
prompt: [
|
||||||
`Generate a batch-cooking session covering ${mealParts.join(" and ") || `${totalDishes} meals`}.`,
|
`Generate a batch-cooking session covering ${mealParts.join(" and ") || `${totalDishes} meals`}.`,
|
||||||
`Design exactly ${totalDishes} distinct dishes, each for ${servings} servings.`,
|
`Design exactly ${totalDishes} distinct dishes, each for ${servings} servings.`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,3 +4,13 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
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, "");
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@
|
|||||||
"mention": "{name} mentioned you in a comment",
|
"mention": "{name} mentioned you in a comment",
|
||||||
"detail": {
|
"detail": {
|
||||||
"comment": "{name} commented on your recipe \"{title}\"",
|
"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": {
|
"pushTitle": {
|
||||||
"follow": "New follower",
|
"follow": "New follower",
|
||||||
@@ -48,7 +49,8 @@
|
|||||||
"reply": "New reply",
|
"reply": "New reply",
|
||||||
"reaction": "New reaction",
|
"reaction": "New reaction",
|
||||||
"rating": "New rating",
|
"rating": "New rating",
|
||||||
"mention": "You were mentioned"
|
"mention": "You were mentioned",
|
||||||
|
"leftoverExpiring": "Leftovers expiring soon"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipe": {
|
"recipe": {
|
||||||
@@ -68,6 +70,10 @@
|
|||||||
"batchCookFridgeDays": "Fridge: {days}d",
|
"batchCookFridgeDays": "Fridge: {days}d",
|
||||||
"batchCookFreezerFriendly": "Freezer-friendly",
|
"batchCookFreezerFriendly": "Freezer-friendly",
|
||||||
"batchCookDayOf": "On the day",
|
"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",
|
"servings": "{count} servings",
|
||||||
"prep": "{mins}m prep",
|
"prep": "{mins}m prep",
|
||||||
"cook": "{mins}m cook",
|
"cook": "{mins}m cook",
|
||||||
@@ -770,6 +776,9 @@
|
|||||||
"canCook": "What can I cook?",
|
"canCook": "What can I cook?",
|
||||||
"expiringSoonSuggestionsTitle": "Use it up soon",
|
"expiringSoonSuggestionsTitle": "Use it up soon",
|
||||||
"seeAll": "See all",
|
"seeAll": "See all",
|
||||||
|
"expiringLeftoversTitle": "Leftovers expiring soon",
|
||||||
|
"expiringToday": "Eat today",
|
||||||
|
"expiringInDays": "{days}d left",
|
||||||
"itemNamePlaceholder": "Item name",
|
"itemNamePlaceholder": "Item name",
|
||||||
"qtyPlaceholder": "Qty",
|
"qtyPlaceholder": "Qty",
|
||||||
"unitPlaceholder": "Unit",
|
"unitPlaceholder": "Unit",
|
||||||
|
|||||||
@@ -40,7 +40,8 @@
|
|||||||
"mention": "{name} vous a mentionné dans un commentaire",
|
"mention": "{name} vous a mentionné dans un commentaire",
|
||||||
"detail": {
|
"detail": {
|
||||||
"comment": "{name} a commenté votre recette « {title} »",
|
"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": {
|
"pushTitle": {
|
||||||
"follow": "Nouvel abonné",
|
"follow": "Nouvel abonné",
|
||||||
@@ -48,7 +49,8 @@
|
|||||||
"reply": "Nouvelle réponse",
|
"reply": "Nouvelle réponse",
|
||||||
"reaction": "Nouvelle réaction",
|
"reaction": "Nouvelle réaction",
|
||||||
"rating": "Nouvelle note",
|
"rating": "Nouvelle note",
|
||||||
"mention": "Vous avez été mentionné"
|
"mention": "Vous avez été mentionné",
|
||||||
|
"leftoverExpiring": "Restes bientôt périmés"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipe": {
|
"recipe": {
|
||||||
@@ -68,6 +70,10 @@
|
|||||||
"batchCookFridgeDays": "Frigo : {days}j",
|
"batchCookFridgeDays": "Frigo : {days}j",
|
||||||
"batchCookFreezerFriendly": "Se congèle",
|
"batchCookFreezerFriendly": "Se congèle",
|
||||||
"batchCookDayOf": "Le jour J",
|
"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",
|
"servings": "{count} portions",
|
||||||
"prep": "{mins}m prép.",
|
"prep": "{mins}m prép.",
|
||||||
"cook": "{mins}m cuisson",
|
"cook": "{mins}m cuisson",
|
||||||
@@ -758,6 +764,9 @@
|
|||||||
"canCook": "Que puis-je cuisiner ?",
|
"canCook": "Que puis-je cuisiner ?",
|
||||||
"expiringSoonSuggestionsTitle": "À utiliser bientôt",
|
"expiringSoonSuggestionsTitle": "À utiliser bientôt",
|
||||||
"seeAll": "Tout voir",
|
"seeAll": "Tout voir",
|
||||||
|
"expiringLeftoversTitle": "Restes bientôt périmés",
|
||||||
|
"expiringToday": "À manger aujourd'hui",
|
||||||
|
"expiringInDays": "{days}j restants",
|
||||||
"itemNamePlaceholder": "Nom de l'article",
|
"itemNamePlaceholder": "Nom de l'article",
|
||||||
"qtyPlaceholder": "Qté",
|
"qtyPlaceholder": "Qté",
|
||||||
"unitPlaceholder": "Unité",
|
"unitPlaceholder": "Unité",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getSessionCookie } from "better-auth/cookies";
|
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"];
|
const ADMIN_PATHS = ["/admin"];
|
||||||
|
|
||||||
export async function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
# Weekly digest — every Monday at 08:00 UTC.
|
# 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
|
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
|
||||||
|
|||||||
Executable
+17
@@ -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,
|
"when": 1783814978348,
|
||||||
"tag": "0030_kind_lightspeed",
|
"tag": "0030_kind_lightspeed",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 31,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783841772890,
|
||||||
|
"tag": "0031_brave_arclight",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
import { recipes } from "./recipes";
|
import { recipes, recipeBatchDishes } from "./recipes";
|
||||||
|
|
||||||
export const notificationTypeEnum = pgEnum("notification_type", [
|
export const notificationTypeEnum = pgEnum("notification_type", [
|
||||||
"follow",
|
"follow",
|
||||||
@@ -87,11 +87,14 @@ export const cookingHistory = pgTable("cooking_history", {
|
|||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
recipeId: text("recipe_id").notNull().references(() => recipes.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(),
|
cookedAt: timestamp("cooked_at").notNull().defaultNow(),
|
||||||
servings: integer("servings"),
|
servings: integer("servings"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
expiryReminderSentAt: timestamp("expiry_reminder_sent_at"),
|
||||||
}, (t) => [
|
}, (t) => [
|
||||||
index("cooking_history_user_idx").on(t.userId),
|
index("cooking_history_user_idx").on(t.userId),
|
||||||
|
index("cooking_history_batch_dish_idx").on(t.batchDishId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const notifications = pgTable("notifications", {
|
export const notifications = pgTable("notifications", {
|
||||||
@@ -111,6 +114,7 @@ export const notifications = pgTable("notifications", {
|
|||||||
export const cookingHistoryRelations = relations(cookingHistory, ({ one }) => ({
|
export const cookingHistoryRelations = relations(cookingHistory, ({ one }) => ({
|
||||||
user: one(users, { fields: [cookingHistory.userId], references: [users.id] }),
|
user: one(users, { fields: [cookingHistory.userId], references: [users.id] }),
|
||||||
recipe: one(recipes, { fields: [cookingHistory.recipeId], references: [recipes.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 }) => ({
|
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user