feat: one-click shopping list from meal plan, fix ingredient merge bug
- Meal-plan page now has a direct "Add to shopping list" action for the currently-viewed week, instead of requiring a trip to Shopping Lists and manually typing the week's Monday date. - Fixed a real under-shopping bug in shopping-lists/route.ts: when generating from a meal-plan week, ingredients appearing in more than one recipe were merged by keeping only the FIRST occurrence's quantity and silently dropping the rest. New mergeIngredients() (pantry-shopping-match.ts) groups by ingredientId (or normalized name+unit) and sums quantities across every recipe in the week; incompatible/unparseable units are kept as separate line items rather than guessed at. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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({
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ShareMealPlanButton weekStart={weekStart} />
|
||||
<NewShoppingListButton
|
||||
defaultWeekStart={weekStart}
|
||||
defaultName={formatMessage(msgs.mealPlan.shoppingListWeekName, { week: label })}
|
||||
/>
|
||||
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
{msgs.mealPlan.shoppingLists}
|
||||
|
||||
@@ -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<string, { rawName: string; quantity?: string; unit?: string; ingredientId: string | null }>();
|
||||
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
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
<Plus className="h-4 w-4" /> {t("newList")}
|
||||
<Button size="sm" onClick={openDialog} variant={defaultWeekStart ? "outline" : "default"}>
|
||||
{defaultWeekStart ? <ShoppingCart className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||
{defaultWeekStart ? t("generateFromThisWeek") : t("newList")}
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
@@ -55,11 +68,16 @@ export function NewShoppingListButton() {
|
||||
<Label>{t("listNameLabel")}</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("listNamePlaceholder")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("generateFromWeek")}</Label>
|
||||
<Input type="date" value={weekStart} onChange={(e) => setWeekStart(e.target.value)} />
|
||||
<p className="text-xs text-muted-foreground">{t("generateFromWeekHint")}</p>
|
||||
</div>
|
||||
{!defaultWeekStart && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("generateFromWeek")}</Label>
|
||||
<Input type="date" value={weekStart} onChange={(e) => setWeekStart(e.target.value)} />
|
||||
<p className="text-xs text-muted-foreground">{t("generateFromWeekHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
{defaultWeekStart && (
|
||||
<p className="text-xs text-muted-foreground">{t("generateFromThisWeekHint")}</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>{tCommon("cancel")}</Button>
|
||||
<Button onClick={create} disabled={!name.trim() || saving}>{saving ? t("listCreating") : t("listCreate")}</Button>
|
||||
|
||||
@@ -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<string, { rawName: string; ingredientId: string | null; unit: string | null; entries: (string | null)[] }>();
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user