feat: pantry drag-to-recategorize, always-show categories, auto-categorize; fix category label + merge quantity mismatch (v0.84.0)

Pantry items can now be dragged between category sections (dnd-kit, cross-category drop only — no sortOrder column to persist within-category reorder). All 9 categories always render as sections, even empty ones, instead of only ones with items. Added an "Auto-categorize" action mirroring the shopping list's guessAisle heuristic.

Fixed: the edit dialog's category dropdown displayed the raw stored slug/"__other__" instead of its translated label (SelectValue needs a value->label render function, same pattern shopping-list-view.tsx already used elsewhere). Fixed: merge-duplicates now merges by ingredient identity alone, not identity+unit — two rows merge even with mismatched, missing, or different-unit quantities, only summing when safe to do so.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 15:51:05 +02:00
parent 93936eae10
commit ebe3216c04
11 changed files with 218 additions and 69 deletions
@@ -11,15 +11,19 @@ function normalizeUnit(unit: string | null): string {
* 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
* for rows added before it existed. Groups by resolved ingredient key alone
* (unit is NOT part of the grouping key — two rows of the same ingredient
* still merge even if one has no quantity/unit at all, or the two use
* different units); 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).
* quantity AND they all share the same unit — mixing units without
* conversion, or mixing a known and an unknown amount, would silently
* invent a number, so the first known (quantity, unit) pair 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);
@@ -33,7 +37,7 @@ export async function POST(req: NextRequest) {
const groups = new Map<string, typeof items>();
for (const item of items) {
const key = `${resolveIngredientKey(item.rawName, aliasIndex)}::${normalizeUnit(item.unit)}`;
const key = resolveIngredientKey(item.rawName, aliasIndex);
const group = groups.get(key) ?? [];
group.push(item);
groups.set(key, group);
@@ -49,10 +53,16 @@ export async function POST(req: NextRequest) {
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 units = group.map((i) => normalizeUnit(i.unit));
const sameUnit = units.every((u) => u === units[0]);
const allParseable = sameUnit && quantities.every((q) => q !== null && !isNaN(q));
const firstKnownIndex = quantities.findIndex((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;
: firstKnownIndex !== -1 ? quantities[firstKnownIndex]!.toString() : survivor!.quantity;
const mergedUnit = allParseable
? survivor!.unit
: firstKnownIndex !== -1 ? group[firstKnownIndex]!.unit : survivor!.unit;
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;
@@ -62,6 +72,7 @@ export async function POST(req: NextRequest) {
await db.update(pantryItems).set({
quantity: mergedQuantity,
unit: mergedUnit,
notes: mergedNotes,
aisle: mergedAisle,
ingredientId: mergedIngredientId,