diff --git a/apps/web/app/(app)/meal-plan/page.tsx b/apps/web/app/(app)/meal-plan/page.tsx index 87f47c8..a1021e7 100644 --- a/apps/web/app/(app)/meal-plan/page.tsx +++ b/apps/web/app/(app)/meal-plan/page.tsx @@ -7,6 +7,7 @@ import { db, mealPlans, mealPlanMembers, recipes, userNutritionGoals, eq, and, d import { buttonVariants } from "@/components/ui/button"; import { MealPlanner } from "@/components/meal-plan/meal-planner"; import { ShareMealPlanButton } from "@/components/meal-plan/share-meal-plan-button"; +import { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list-button"; import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar"; import { cn } from "@/lib/utils"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; @@ -100,6 +101,10 @@ export default async function MealPlanPage({
+ {msgs.mealPlan.shoppingLists} diff --git a/apps/web/app/api/v1/shopping-lists/route.ts b/apps/web/app/api/v1/shopping-lists/route.ts index 688e312..30e4615 100644 --- a/apps/web/app/api/v1/shopping-lists/route.ts +++ b/apps/web/app/api/v1/shopping-lists/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { db, shoppingLists, shoppingListItems, mealPlans, mealPlanEntries, recipeIngredients, pantryItems, eq, and, desc, inArray } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; -import { applyPantryToItems } from "@/lib/pantry-shopping-match"; +import { applyPantryToItems, mergeIngredients } from "@/lib/pantry-shopping-match"; const CreateSchema = z.object({ name: z.string().min(1).max(100), @@ -54,20 +54,10 @@ export async function POST(req: NextRequest) { where: inArray(recipeIngredients.recipeId, recipeIds), }); - // Simple merge by rawName (case-insensitive) - const merged = new Map(); - for (const ing of ings) { - const key = ing.rawName.toLowerCase(); - if (!merged.has(key)) { - merged.set(key, { - rawName: ing.rawName, - quantity: ing.quantity ?? undefined, - unit: ing.unit ?? undefined, - ingredientId: ing.ingredientId, - }); - } - } - const mergedItems = Array.from(merged.values()); + // Merge same ingredient across all of the week's recipes, summing quantities + // (grouped by ingredientId when available, else normalized name+unit) instead + // of listing it once per recipe or dropping every occurrence but the first. + const mergedItems = mergeIngredients(ings); // Reduce/flag quantities already covered by the user's pantry. Conservative: never silently // drops an item — fully-covered items are still inserted, flagged `inPantry`, so nothing diff --git a/apps/web/components/meal-plan/new-shopping-list-button.tsx b/apps/web/components/meal-plan/new-shopping-list-button.tsx index 1d7a010..6de24ea 100644 --- a/apps/web/components/meal-plan/new-shopping-list-button.tsx +++ b/apps/web/components/meal-plan/new-shopping-list-button.tsx @@ -10,15 +10,27 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -export function NewShoppingListButton() { +export function NewShoppingListButton({ + defaultWeekStart, + defaultName, +}: { + /** Pre-fills and locks the source week, e.g. when generating from the currently-viewed meal-plan week. */ + defaultWeekStart?: string; + defaultName?: string; +}) { const t = useTranslations("mealPlan"); const tCommon = useTranslations("common"); const router = useRouter(); const [open, setOpen] = useState(false); - const [name, setName] = useState(""); - const [weekStart, setWeekStart] = useState(""); + const [name, setName] = useState(defaultName ?? ""); + const [weekStart, setWeekStart] = useState(defaultWeekStart ?? ""); const [saving, setSaving] = useState(false); + function openDialog() { + if (defaultWeekStart) { setName(defaultName ?? ""); setWeekStart(defaultWeekStart); } + setOpen(true); + } + async function create() { if (!name.trim()) return; setSaving(true); @@ -44,8 +56,9 @@ export function NewShoppingListButton() { return ( <> - @@ -55,11 +68,16 @@ export function NewShoppingListButton() { setName(e.target.value)} placeholder={t("listNamePlaceholder")} />
-
- - setWeekStart(e.target.value)} /> -

{t("generateFromWeekHint")}

-
+ {!defaultWeekStart && ( +
+ + setWeekStart(e.target.value)} /> +

{t("generateFromWeekHint")}

+
+ )} + {defaultWeekStart && ( +

{t("generateFromThisWeekHint")}

+ )}
diff --git a/apps/web/lib/pantry-shopping-match.ts b/apps/web/lib/pantry-shopping-match.ts index 11cafec..6b11935 100644 --- a/apps/web/lib/pantry-shopping-match.ts +++ b/apps/web/lib/pantry-shopping-match.ts @@ -33,17 +33,17 @@ export type PantryAdjustedItem = { inPantry: boolean; }; -function normalizeName(name: string): string { +export function normalizeName(name: string): string { const trimmed = name.toLowerCase().trim().replace(/\s+/g, " "); // Simple plural trim — strip a single trailing "s" for words longer than 3 chars. return trimmed.length > 3 && trimmed.endsWith("s") ? trimmed.slice(0, -1) : trimmed; } -function normalizeUnit(unit: string | null | undefined): string { +export function normalizeUnit(unit: string | null | undefined): string { return (unit ?? "").toLowerCase().trim(); } -function formatQuantity(n: number): string { +export function formatQuantity(n: number): string { return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.?0+$/, ""); } @@ -108,3 +108,60 @@ export function applyPantryToItems( return { rawName: item.rawName, quantity: item.quantity, unit: item.unit, inPantry: true }; }); } + +export type MergeSourceIngredient = { + rawName: string; + quantity: string | null; + unit: string | null; + ingredientId: string | null; +}; + +export type MergedIngredient = { + rawName: string; + quantity?: string; + unit?: string; + ingredientId: string | null; +}; + +/** + * Merges ingredients across recipes (e.g. an entire week's meal plan) so a shopping + * list doesn't show the same ingredient once per recipe. Groups by normalized + * ingredientId (if present) or normalized name+unit, then SUMS quantities across the + * group — the earlier version of this logic kept only the first occurrence and + * silently dropped every other recipe's requirement, which under-shopped whenever the + * same ingredient appeared in more than one recipe. + * + * Items with the same name but incompatible/unparseable units are kept as separate + * line items rather than guessed at — never invent a converted total. + */ +export function mergeIngredients(ingredients: MergeSourceIngredient[]): MergedIngredient[] { + const groups = new Map(); + + for (const ing of ingredients) { + const unitKey = normalizeUnit(ing.unit); + const groupKey = ing.ingredientId + ? `id:${ing.ingredientId}:${unitKey}` + : `name:${normalizeName(ing.rawName)}:${unitKey}`; + + let group = groups.get(groupKey); + if (!group) { + group = { rawName: ing.rawName, ingredientId: ing.ingredientId, unit: ing.unit, entries: [] }; + groups.set(groupKey, group); + } + group.entries.push(ing.quantity); + } + + return Array.from(groups.values()).map((group) => { + const parsed = group.entries.map((q) => (q ? parseFloat(q) : NaN)); + const allParseable = parsed.length > 0 && parsed.every((n) => !isNaN(n)); + + if (allParseable) { + const total = parsed.reduce((sum, n) => sum + n, 0); + return { rawName: group.rawName, quantity: formatQuantity(total), unit: group.unit ?? undefined, ingredientId: group.ingredientId }; + } + + // Unparseable or mixed with/without quantity — can't sum reliably, don't guess. + // Fall back to no quantity shown rather than an incorrect total. + return { rawName: group.rawName, quantity: undefined, unit: group.unit ?? undefined, ingredientId: group.ingredientId }; + }); +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index d4bb475..622567b 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -699,6 +699,9 @@ "listNamePlaceholder": "Weekly groceries", "generateFromWeek": "Generate from meal plan week (optional)", "generateFromWeekHint": "Picks Monday of the selected week", + "generateFromThisWeek": "Add to shopping list", + "generateFromThisWeekHint": "Merges every recipe's ingredients from this week, summing matching ones and flagging what's already in your pantry.", + "shoppingListWeekName": "Groceries for {week}", "listCreateFailed": "Failed to create", "listCreating": "Creating…", "listCreate": "Create", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index c1e19b7..2c4133e 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -687,6 +687,9 @@ "listNamePlaceholder": "Courses de la semaine", "generateFromWeek": "Générer depuis une semaine du planning (facultatif)", "generateFromWeekHint": "Sélectionne le lundi de la semaine choisie", + "generateFromThisWeek": "Ajouter à la liste de courses", + "generateFromThisWeekHint": "Fusionne les ingrédients de toutes les recettes de cette semaine, additionne les quantités identiques et signale ce que vous avez déjà au garde-manger.", + "shoppingListWeekName": "Courses pour {week}", "listCreateFailed": "Échec de la création", "listCreating": "Création…", "listCreate": "Créer",