diff --git a/apps/web/app/(app)/pantry/page.tsx b/apps/web/app/(app)/pantry/page.tsx
index 19a1174..48aaa2e 100644
--- a/apps/web/app/(app)/pantry/page.tsx
+++ b/apps/web/app/(app)/pantry/page.tsx
@@ -1,9 +1,13 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
-import { db, pantryItems, eq, asc } from "@epicure/db";
+import { db, pantryItems, recipes, eq, or, inArray } 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 { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
+import { getPublicUrl } from "@/lib/storage";
export const metadata: Metadata = {};
@@ -11,10 +15,16 @@ export default async function PantryPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
- const items = await db.query.pantryItems.findMany({
- where: eq(pantryItems.userId, session.user.id),
- orderBy: asc(pantryItems.rawName),
- });
+ const [items, candidateRecipes] = await Promise.all([
+ db.query.pantryItems.findMany({
+ where: eq(pantryItems.userId, session.user.id),
+ orderBy: asc(pantryItems.rawName),
+ }),
+ db.query.recipes.findMany({
+ where: or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"])),
+ with: { ingredients: true, photos: true },
+ }),
+ ]);
const mappedItems = items.map((i) => ({
id: i.id,
@@ -24,9 +34,23 @@ export default async function PantryPage() {
expiresAt: i.expiresAt?.toISOString() ?? null,
}));
+ const suggestions = scoreRecipesAgainstPantry(candidateRecipes, items)
+ .filter((s) => s.usesExpiring.length > 0)
+ .slice(0, 3)
+ .map((s) => {
+ const cover = s.recipe.photos?.find((p) => p.isCover) ?? s.recipe.photos?.[0];
+ return {
+ id: s.recipe.id,
+ title: s.recipe.title,
+ photoUrl: cover ? getPublicUrl(cover.storageKey) : null,
+ usesExpiring: s.usesExpiring,
+ };
+ });
+
return (
);
diff --git a/apps/web/app/(app)/recipes/can-cook/page.tsx b/apps/web/app/(app)/recipes/can-cook/page.tsx
index c131f19..5243a6c 100644
--- a/apps/web/app/(app)/recipes/can-cook/page.tsx
+++ b/apps/web/app/(app)/recipes/can-cook/page.tsx
@@ -5,17 +5,10 @@ import { db, recipes, pantryItems } from "@epicure/db";
import { eq } from "@epicure/db";
import { getPublicUrl } from "@/lib/storage";
import { CanCookContent } from "@/components/recipe/can-cook-content";
+import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
export const metadata: Metadata = {};
-const EXPIRING_WITHIN_DAYS = 3;
-
-function isExpiringSoon(expiresAt: Date | null): boolean {
- if (!expiresAt) return false;
- const days = Math.ceil((expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
- return days >= 0 && days <= EXPIRING_WITHIN_DAYS;
-}
-
export default async function CanCookPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
@@ -33,49 +26,18 @@ export default async function CanCookPage() {
}),
]);
- const pantryKeys = new Set(pantry.map((p) => p.rawName.toLowerCase()));
-
- const expiringSoonKeys = new Set(
- pantry
- .filter((p) => isExpiringSoon(p.expiresAt))
- .map((p) => p.rawName.toLowerCase())
- );
-
- const scored = userRecipes
- .filter((r) => r.ingredients.length > 0)
- .map((recipe) => {
- const matched = recipe.ingredients.filter((ing) =>
- pantryKeys.has(ing.rawName.toLowerCase())
- ).length;
- const missing = recipe.ingredients
- .filter((ing) => !pantryKeys.has(ing.rawName.toLowerCase()))
- .map((ing) => ing.rawName)
- .slice(0, 5);
- const usesExpiring = recipe.ingredients
- .filter((ing) => expiringSoonKeys.has(ing.rawName.toLowerCase()))
- .map((ing) => ing.rawName);
- const total = recipe.ingredients.length;
- const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
- return {
- recipe: {
- id: recipe.id,
- title: recipe.title,
- description: recipe.description,
- photos: cover ? [{ url: getPublicUrl(cover.storageKey) }] : [],
- },
- matched,
- total,
- pct: Math.round((matched / total) * 100),
- missing,
- usesExpiring,
- };
- })
- .sort((a, b) => {
- if (a.usesExpiring.length > 0 !== b.usesExpiring.length > 0) {
- return a.usesExpiring.length > 0 ? -1 : 1;
- }
- return b.pct - a.pct;
- });
+ const scored = scoreRecipesAgainstPantry(userRecipes, pantry).map((s) => {
+ const cover = s.recipe.photos?.find((p) => p.isCover) ?? s.recipe.photos?.[0];
+ return {
+ ...s,
+ recipe: {
+ id: s.recipe.id,
+ title: s.recipe.title,
+ description: s.recipe.description,
+ photos: cover ? [{ url: getPublicUrl(cover.storageKey) }] : [],
+ },
+ };
+ });
return ;
}
diff --git a/apps/web/components/pantry/expiring-soon-suggestions.tsx b/apps/web/components/pantry/expiring-soon-suggestions.tsx
new file mode 100644
index 0000000..612aef1
--- /dev/null
+++ b/apps/web/components/pantry/expiring-soon-suggestions.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+import { Clock } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+
+type Suggestion = {
+ id: string;
+ title: string;
+ photoUrl: string | null;
+ usesExpiring: string[];
+};
+
+export function ExpiringSoonSuggestions({ suggestions }: { suggestions: Suggestion[] }) {
+ const t = useTranslations("pantry");
+
+ if (suggestions.length === 0) return null;
+
+ return (
+
+
+
+
+ {t("expiringSoonSuggestionsTitle")}
+
+
+ {t("seeAll")}
+
+
+
+ {suggestions.map((s) => (
+
+ {s.photoUrl ? (
+

+ ) : (
+
+ )}
+
+
{s.title}
+
+
+ {s.usesExpiring.slice(0, 2).join(", ")}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/lib/pantry-match.ts b/apps/web/lib/pantry-match.ts
new file mode 100644
index 0000000..353a9eb
--- /dev/null
+++ b/apps/web/lib/pantry-match.ts
@@ -0,0 +1,40 @@
+export const EXPIRING_WITHIN_DAYS = 3;
+
+export function isExpiringSoon(expiresAt: Date | null): boolean {
+ if (!expiresAt) return false;
+ const days = Math.ceil((expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
+ return days >= 0 && days <= EXPIRING_WITHIN_DAYS;
+}
+
+type ScorableRecipe = T & { ingredients: { rawName: string }[] };
+
+export function scoreRecipesAgainstPantry(
+ recipesList: ScorableRecipe[],
+ pantry: { rawName: string; expiresAt: Date | null }[]
+) {
+ const pantryKeys = new Set(pantry.map((p) => p.rawName.toLowerCase()));
+ const expiringSoonKeys = new Set(
+ pantry.filter((p) => isExpiringSoon(p.expiresAt)).map((p) => p.rawName.toLowerCase())
+ );
+
+ return recipesList
+ .filter((r) => r.ingredients.length > 0)
+ .map((recipe) => {
+ const matched = recipe.ingredients.filter((ing) => pantryKeys.has(ing.rawName.toLowerCase())).length;
+ const missing = recipe.ingredients
+ .filter((ing) => !pantryKeys.has(ing.rawName.toLowerCase()))
+ .map((ing) => ing.rawName)
+ .slice(0, 5);
+ const usesExpiring = recipe.ingredients
+ .filter((ing) => expiringSoonKeys.has(ing.rawName.toLowerCase()))
+ .map((ing) => ing.rawName);
+ const total = recipe.ingredients.length;
+ return { recipe, matched, total, pct: Math.round((matched / total) * 100), missing, usesExpiring };
+ })
+ .sort((a, b) => {
+ if (a.usesExpiring.length > 0 !== b.usesExpiring.length > 0) {
+ return a.usesExpiring.length > 0 ? -1 : 1;
+ }
+ return b.pct - a.pct;
+ });
+}
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 642726a..f7deb71 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -622,6 +622,8 @@
"title": "Pantry",
"subtitle": "Track what you have on hand",
"canCook": "What can I cook?",
+ "expiringSoonSuggestionsTitle": "Use it up soon",
+ "seeAll": "See all",
"itemNamePlaceholder": "Item name",
"qtyPlaceholder": "Qty",
"unitPlaceholder": "Unit",
diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json
index 8f91188..37bf696 100644
--- a/apps/web/messages/fr.json
+++ b/apps/web/messages/fr.json
@@ -610,6 +610,8 @@
"title": "Garde-manger",
"subtitle": "Suivez ce que vous avez en stock",
"canCook": "Que puis-je cuisiner ?",
+ "expiringSoonSuggestionsTitle": "À utiliser bientôt",
+ "seeAll": "Tout voir",
"itemNamePlaceholder": "Nom de l'article",
"qtyPlaceholder": "Qté",
"unitPlaceholder": "Unité",