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é",