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
+118 -30
View File
@@ -3,11 +3,14 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Plus, Trash2, AlertTriangle, Package } from "lucide-react";
import { Plus, Trash2, AlertTriangle, Package, Pencil, ChevronDown, Merge } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { formatQuantity, hasQuantity } from "@/lib/fractions";
import { PantryScanDialog } from "@/components/pantry/pantry-scan-dialog";
import { PantryItemDialog, type PantryItem } from "@/components/pantry/pantry-item-dialog";
import { EmptyState } from "@/components/shared/empty-state";
import {
AlertDialog,
@@ -20,13 +23,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
type PantryItem = {
id: string;
rawName: string;
quantity: string | null;
unit: string | null;
expiresAt: string | null;
};
const OTHER_KEY = "__other__";
function daysUntilExpiry(dateStr: string): number {
const diff = new Date(dateStr).getTime() - Date.now();
@@ -35,6 +32,7 @@ function daysUntilExpiry(dateStr: string): number {
export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) {
const t = useTranslations("pantry");
const tShopping = useTranslations("shoppingLists");
const tCommon = useTranslations("common");
const router = useRouter();
const [items, setItems] = useState<PantryItem[]>(initialItems);
@@ -44,6 +42,23 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
const [expiresAt, setExpiresAt] = useState("");
const [adding, setAdding] = useState(false);
const [confirmId, setConfirmId] = useState<string | null>(null);
const [editingItem, setEditingItem] = useState<PantryItem | null>(null);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [merging, setMerging] = useState(false);
async function mergeDuplicates() {
setMerging(true);
try {
const res = await fetch("/api/v1/pantry/merge-duplicates", { method: "POST" });
if (!res.ok) { toast.error(t("mergeDuplicatesFailed")); return; }
const { removed } = await res.json() as { mergedGroups: number; removed: number };
if (removed === 0) toast.success(t("mergeDuplicatesNoneFound"));
else toast.success(t("mergeDuplicatesSuccess", { count: removed }));
router.refresh();
} finally {
setMerging(false);
}
}
async function add() {
if (!name.trim()) return;
@@ -61,7 +76,10 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
});
if (!res.ok) { toast.error(t("addFailed")); return; }
const { id } = await res.json() as { id: string };
setItems((prev) => [...prev, { id, rawName: name.trim(), quantity: quantity || null, unit: unit || null, expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null }]);
setItems((prev) => [
...prev,
{ id, rawName: name.trim(), quantity: quantity || null, unit: unit || null, notes: null, aisle: null, expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null },
]);
setName(""); setQuantity(""); setUnit(""); setExpiresAt("");
} finally {
setAdding(false);
@@ -74,14 +92,38 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
else toast.error(t("removeFailed"));
}
function toggleCollapsed(key: string) {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
const itemPendingDelete = items.find((i) => i.id === confirmId) ?? null;
const sorted = [...items].sort((a, b) => {
const sortWithinGroup = (a: PantryItem, b: PantryItem) => {
if (a.expiresAt && b.expiresAt) return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime();
if (a.expiresAt) return -1;
if (b.expiresAt) return 1;
return a.rawName.localeCompare(b.rawName);
});
};
const grouped = new Map<string, PantryItem[]>();
for (const item of items) {
const key = item.aisle ?? OTHER_KEY;
const group = grouped.get(key) ?? [];
group.push(item);
grouped.set(key, group);
}
for (const group of grouped.values()) group.sort(sortWithinGroup);
function categoryLabel(key: string): string {
return key === OTHER_KEY ? tShopping("aisleOther") : tShopping(`categories.${key}`);
}
const sortedGroupKeys = [...grouped.keys()].sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b)));
const showGroupHeaders = grouped.size > 1;
return (
<div className="space-y-6 max-w-2xl">
@@ -95,33 +137,67 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
<Plus className="h-4 w-4" /> {t("add")}
</Button>
<PantryScanDialog onAdded={() => router.refresh()} />
<Button variant="outline" size="sm" onClick={() => { void mergeDuplicates(); }} disabled={merging}>
<Merge className="h-4 w-4" /> {t("mergeDuplicates")}
</Button>
</div>
{/* Item list */}
{items.length === 0 ? (
<EmptyState icon={Package} title={t("empty")} description={t("emptyDescription")} compact />
) : (
<div className="rounded-xl border divide-y">
{sorted.map((item) => {
const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null;
const expiring = days !== null && days <= 3;
const expired = days !== null && days < 0;
<div className="space-y-4">
{sortedGroupKeys.map((key) => {
const groupItems = grouped.get(key)!;
const isCollapsed = collapsed.has(key);
return (
<div key={item.id} className="flex items-center gap-3 px-4 py-3 hover:bg-muted/30">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{item.rawName}</span>
{item.quantity && <span className="text-xs text-muted-foreground">{item.quantity}{item.unit ? ` ${item.unit}` : ""}</span>}
{expired && <span className="text-xs text-destructive flex items-center gap-1"><AlertTriangle className="h-3 w-3" />{t("expired")}</span>}
{expiring && !expired && <span className="text-xs text-orange-500 flex items-center gap-1"><AlertTriangle className="h-3 w-3" />{t("expiresInDays", { days })}</span>}
<div key={key} className="rounded-xl border overflow-hidden">
{showGroupHeaders && (
<button
type="button"
onClick={() => toggleCollapsed(key)}
className="w-full flex items-center justify-between gap-2 px-4 py-2 bg-muted/40 hover:bg-muted/60 transition-colors text-left"
>
<span className="text-sm font-medium">{categoryLabel(key)}</span>
<span className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{groupItems.length}</span>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform", isCollapsed && "-rotate-90")} />
</span>
</button>
)}
{!isCollapsed && (
<div className="divide-y">
{groupItems.map((item) => {
const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null;
const expiring = days !== null && days <= 3;
const expired = days !== null && days < 0;
return (
<div key={item.id} className="flex items-center gap-3 px-4 py-3 hover:bg-muted/30">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{item.rawName}</span>
{hasQuantity(item.quantity) && (
<span className="text-xs text-muted-foreground">{formatQuantity(parseFloat(item.quantity!))}{item.unit ? ` ${item.unit}` : ""}</span>
)}
{expired && <span className="text-xs text-destructive flex items-center gap-1"><AlertTriangle className="h-3 w-3" />{t("expired")}</span>}
{expiring && !expired && <span className="text-xs text-orange-500 flex items-center gap-1"><AlertTriangle className="h-3 w-3" />{t("expiresInDays", { days })}</span>}
</div>
{item.expiresAt && !expired && !expiring && (
<p className="text-xs text-muted-foreground">{t("expiresOn", { date: new Date(item.expiresAt).toLocaleDateString() })}</p>
)}
{item.notes && <p className="text-xs text-muted-foreground italic mt-0.5">{item.notes}</p>}
</div>
<button onClick={() => setEditingItem(item)} aria-label={t("editItem")} className="text-muted-foreground hover:text-foreground transition-colors">
<Pencil className="h-4 w-4" />
</button>
<button onClick={() => setConfirmId(item.id)} className="text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="h-4 w-4" />
</button>
</div>
);
})}
</div>
{item.expiresAt && !expired && !expiring && (
<p className="text-xs text-muted-foreground">{t("expiresOn", { date: new Date(item.expiresAt).toLocaleDateString() })}</p>
)}
</div>
<button onClick={() => setConfirmId(item.id)} className="text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
);
})}
@@ -150,6 +226,18 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{editingItem && (
<PantryItemDialog
item={editingItem}
open={!!editingItem}
onOpenChange={(open) => !open && setEditingItem(null)}
onSaved={(updated) => {
setItems((prev) => prev.map((i) => (i.id === updated.id ? updated : i)));
setEditingItem(null);
}}
/>
)}
</div>
);
}
@@ -34,7 +34,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { hasQuantity } from "@/lib/fractions";
import { hasQuantity, formatQuantity } from "@/lib/fractions";
import { guessAisle, GROCERY_CATEGORIES } from "@/lib/grocery-categories";
import {
DndContext,
@@ -694,7 +694,7 @@ function ItemRow({ item, readOnly, categoryOptions, categoryLabel, tShopping, on
)}
{(hasQuantity(item.quantity) || item.unit) && (
<span className={cn("text-xs text-muted-foreground tabular-nums shrink-0", item.checked && "opacity-50")}>
{hasQuantity(item.quantity) ? item.quantity : ""}{item.unit ? ` ${item.unit}` : ""}
{hasQuantity(item.quantity) ? formatQuantity(parseFloat(item.quantity!)) : ""}{item.unit ? ` ${item.unit}` : ""}
</span>
)}
</button>