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:
Arnaud
2026-07-24 15:13:33 +02:00
parent a488b544dc
commit 93936eae10
37 changed files with 7255 additions and 117 deletions
+10 -1
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, pantryItems, eq, and } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { findIngredientIdByName } from "@/lib/ingredient-match";
type Params = { params: Promise<{ id: string }> };
@@ -18,15 +19,23 @@ export async function PUT(req: NextRequest, { params }: Params) {
rawName: z.string().min(1).max(200).optional(),
quantity: z.string().nullable().optional(),
unit: z.string().nullable().optional(),
notes: z.string().max(500).nullable().optional(),
aisle: z.string().max(50).nullable().optional(),
expiresAt: z.string().datetime().nullable().optional(),
}).safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const data = parsed.data;
// Renaming can change which canonical ingredient this item resolves to
// (e.g. "sel" -> "sel de table") — re-resolve whenever rawName changes,
// rather than leaving a stale link from the item's original name.
const ingredientId = data.rawName ? await findIngredientIdByName(data.rawName) : undefined;
await db.update(pantryItems).set({
...(data.rawName && { rawName: data.rawName }),
...(data.rawName && { rawName: data.rawName, ingredientId }),
...(data.quantity !== undefined && { quantity: data.quantity ?? undefined }),
...(data.unit !== undefined && { unit: data.unit ?? undefined }),
...(data.notes !== undefined && { notes: data.notes ?? undefined }),
...(data.aisle !== undefined && { aisle: data.aisle ?? undefined }),
...(data.expiresAt !== undefined && { expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined }),
}).where(eq(pantryItems.id, id));
+14 -7
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, pantryItems, eq } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { loadIngredientAliasIndex, resolveIngredientKey, findIngredientIdByName } from "@/lib/ingredient-match";
const Schema = z.object({
items: z.array(z.object({
@@ -20,14 +21,15 @@ export async function POST(req: NextRequest) {
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const userId = session!.user.id;
const existing = await db.query.pantryItems.findMany({
where: eq(pantryItems.userId, userId),
});
const [existing, aliasIndex] = await Promise.all([
db.query.pantryItems.findMany({ where: eq(pantryItems.userId, userId) }),
loadIngredientAliasIndex(),
]);
for (const incoming of parsed.data.items) {
const key = incoming.rawName.toLowerCase();
const key = resolveIngredientKey(incoming.rawName, aliasIndex);
const match = existing.find(
(e) => e.rawName.toLowerCase() === key && (e.unit ?? "") === (incoming.unit ?? "")
(e) => resolveIngredientKey(e.rawName, aliasIndex) === key && (e.unit ?? "") === (incoming.unit ?? "")
);
if (match) {
@@ -41,13 +43,18 @@ export async function POST(req: NextRequest) {
}
// if quantities aren't numeric, leave as-is (item already exists)
} else {
await db.insert(pantryItems).values({
const ingredientId = await findIngredientIdByName(incoming.rawName);
const created = {
id: crypto.randomUUID(),
userId,
ingredientId,
rawName: incoming.rawName,
quantity: incoming.quantity,
unit: incoming.unit,
});
};
await db.insert(pantryItems).values(created);
// Later items in this same batch can now also match this one.
existing.push({ ...created, notes: null, aisle: null, expiresAt: null, quantity: created.quantity ?? null, unit: created.unit ?? null, createdAt: new Date() });
}
}
@@ -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 });
}
+7
View File
@@ -2,11 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, pantryItems, eq, desc, sql } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { findIngredientIdByName } from "@/lib/ingredient-match";
const Schema = z.object({
rawName: z.string().min(1).max(200),
quantity: z.string().optional(),
unit: z.string().optional(),
notes: z.string().max(500).optional(),
aisle: z.string().max(50).optional(),
expiresAt: z.string().datetime().optional(),
});
@@ -47,12 +50,16 @@ export async function POST(req: NextRequest) {
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const id = crypto.randomUUID();
const ingredientId = await findIngredientIdByName(parsed.data.rawName);
await db.insert(pantryItems).values({
id,
userId: session!.user.id,
ingredientId,
rawName: parsed.data.rawName,
quantity: parsed.data.quantity,
unit: parsed.data.unit,
notes: parsed.data.notes,
aisle: parsed.data.aisle,
expiresAt: parsed.data.expiresAt ? new Date(parsed.data.expiresAt) : undefined,
});