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 (
<>
-
-
-
- setWeekStart(e.target.value)} />
-
{t("generateFromWeekHint")}
-
+ {!defaultWeekStart && (
+
+
+ setWeekStart(e.target.value)} />
+
{t("generateFromWeekHint")}
+
+ )}
+ {defaultWeekStart && (
+
{t("generateFromThisWeekHint")}
+ )}
setOpen(false)}>{tCommon("cancel")}{saving ? t("listCreating") : t("listCreate")}
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",