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:
Arnaud
2026-07-10 09:45:51 +02:00
parent 9c545a5bb3
commit d62e2a6383
6 changed files with 104 additions and 28 deletions
+60 -3
View File
@@ -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 };
});
}