feat: pantry notes/categories, ingredient-alias matching, cook-log edit/delete, fork-list popover (v0.83.0)
Pantry: notes + category fields (collapsible grouping like the shopping list), a "Merge duplicates" cleanup action, and fixed quantity display precision (was showing raw decimal(10,4) strings like "0.3333 kg" everywhere — pantry, shopping list, print views, Markdown exports). Ingredient-alias matching: the ingredients table (canonical name + aliases) existed but was never populated or used. Seeded ~10 bilingual EN/FR staples and wired resolution into pantry add/edit, can-cook scoring, auto-deduct-on-cook, and shopping-list pantry-awareness, so "sel"/"sel fin"/"table salt" are recognized as the same ingredient. Cook log: entries from "Mark cooked" can now be edited and deleted (previously log-only, no fix-a-mistake path). The "Cooked N times" text is a hover tooltip listing every date and opens a full manage sheet on click. Also: the "Forked by N others" backlink is now a click-to-open popover instead of an always-inline list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, pantryItems, eq, asc, inArray } from "@epicure/db";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
import { loadIngredientAliasIndex, resolveIngredientKey } from "@/lib/ingredient-match";
|
||||
|
||||
function normalizeUnit(unit: string | null): string {
|
||||
return (unit ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot cleanup for pantry items that turn out to be the same ingredient
|
||||
* under different names ("sel", "sel fin", "sel de table") — a case the
|
||||
* alias index (lib/ingredient-match.ts) only prevents going forward, not
|
||||
* for rows added before it existed. Groups by resolved ingredient key +
|
||||
* normalized unit; for any group with more than one row, merges into the
|
||||
* oldest row and deletes the rest.
|
||||
*
|
||||
* Quantities are only summed when every row in the group has a parseable
|
||||
* quantity — mixing a known and an unknown amount would silently invent a
|
||||
* number, so the first known quantity is kept instead. Notes are
|
||||
* concatenated (nothing is dropped); expiresAt keeps the soonest date
|
||||
* (the conservative choice — better to under-promise freshness than over).
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const { session, response } = await requireSessionOrApiKey(req);
|
||||
if (response) return response;
|
||||
const userId = session!.user.id;
|
||||
|
||||
const [items, aliasIndex] = await Promise.all([
|
||||
db.query.pantryItems.findMany({ where: eq(pantryItems.userId, userId), orderBy: asc(pantryItems.createdAt) }),
|
||||
loadIngredientAliasIndex(),
|
||||
]);
|
||||
|
||||
const groups = new Map<string, typeof items>();
|
||||
for (const item of items) {
|
||||
const key = `${resolveIngredientKey(item.rawName, aliasIndex)}::${normalizeUnit(item.unit)}`;
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(item);
|
||||
groups.set(key, group);
|
||||
}
|
||||
|
||||
let mergedGroups = 0;
|
||||
let removed = 0;
|
||||
const idsToDelete: string[] = [];
|
||||
|
||||
for (const group of groups.values()) {
|
||||
if (group.length < 2) continue;
|
||||
mergedGroups++;
|
||||
|
||||
const [survivor, ...rest] = group;
|
||||
const quantities = group.map((i) => (i.quantity ? parseFloat(i.quantity) : null));
|
||||
const allParseable = quantities.every((q) => q !== null && !isNaN(q));
|
||||
const mergedQuantity = allParseable
|
||||
? String(quantities.reduce((sum, q) => sum! + q!, 0))
|
||||
: quantities.find((q) => q !== null && !isNaN(q))?.toString() ?? survivor!.quantity;
|
||||
|
||||
const mergedNotes = [...new Set(group.map((i) => i.notes?.trim()).filter((n): n is string => !!n))].join("; ") || null;
|
||||
const mergedAisle = group.find((i) => i.aisle)?.aisle ?? null;
|
||||
const mergedIngredientId = group.find((i) => i.ingredientId)?.ingredientId ?? null;
|
||||
const expiryDates = group.map((i) => i.expiresAt).filter((d): d is Date => d !== null);
|
||||
const mergedExpiresAt = expiryDates.length > 0 ? new Date(Math.min(...expiryDates.map((d) => d.getTime()))) : null;
|
||||
|
||||
await db.update(pantryItems).set({
|
||||
quantity: mergedQuantity,
|
||||
notes: mergedNotes,
|
||||
aisle: mergedAisle,
|
||||
ingredientId: mergedIngredientId,
|
||||
expiresAt: mergedExpiresAt,
|
||||
}).where(eq(pantryItems.id, survivor!.id));
|
||||
|
||||
idsToDelete.push(...rest.map((i) => i.id));
|
||||
removed += rest.length;
|
||||
}
|
||||
|
||||
if (idsToDelete.length > 0) {
|
||||
await db.delete(pantryItems).where(inArray(pantryItems.id, idsToDelete));
|
||||
}
|
||||
|
||||
return NextResponse.json({ mergedGroups, removed });
|
||||
}
|
||||
Reference in New Issue
Block a user