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
+13 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.83.0";
export const APP_VERSION = "0.84.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,18 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.84.0",
date: "2026-07-24 20:00",
added: [
"Pantry items can be dragged between categories, like the shopping list. All 9 categories now always show (even empty ones) as drop targets, instead of only categories that already have items.",
"Pantry has an \"Auto-categorize\" action, same heuristic as the shopping list's.",
],
fixed: [
"Pantry item edit dialog's category dropdown showed the raw stored value (e.g. \"__other__\", \"produce\") instead of its translated label.",
"Merging duplicate pantry items now works even when quantities differ, are missing, or use different units — previously two rows only merged if their units matched exactly, so \"garlic\" (no quantity) and \"ail\" (3, no unit) wouldn't merge at all.",
],
},
{
version: "0.83.0",
date: "2026-07-24 19:00",
+1 -1
View File
@@ -443,7 +443,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "post", path: "/api/v1/pantry", summary: "Add a pantry item", description: "rawName is resolved against the canonical ingredients table (name or alias, case-insensitive) to link ingredientId when there's a match — used for cross-recipe/pantry matching, not exposed as a settable field here.", security, request: { body: { content: { "application/json": { 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() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/pantry/{id}", summary: "Update a pantry item", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ 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() }) } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/pantry/{id}", summary: "Delete a pantry item", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/pantry/merge-duplicates", summary: "Merge pantry items that resolve to the same ingredient", description: "One-shot cleanup for items added under different names before ingredient-alias matching existed (e.g. \"sel\"/\"sel fin\"/\"sel de table\"). Groups by resolved ingredient key + normalized unit; quantities are summed only when every row in a group has a parseable quantity, otherwise the first known quantity is kept rather than guessed. Notes are concatenated, never dropped; expiresAt keeps the soonest date in the group.", security, responses: { 200: { description: "Merge result", content: { "application/json": { schema: z.object({ mergedGroups: z.number().int(), removed: z.number().int() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/pantry/merge-duplicates", summary: "Merge pantry items that resolve to the same ingredient", description: "One-shot cleanup for items added under different names before ingredient-alias matching existed (e.g. \"sel\"/\"sel fin\"/\"sel de table\"). Groups by resolved ingredient key alone (unit is not part of the grouping — two rows merge even with different or missing quantities/units); quantities are summed only when every row in a group has a parseable quantity AND the same unit, otherwise the first known (quantity, unit) pair is kept rather than guessed. Notes are concatenated, never dropped; expiresAt keeps the soonest date in the group.", security, responses: { 200: { description: "Merge result", content: { "application/json": { schema: z.object({ mergedGroups: z.number().int(), removed: z.number().int() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/pantry/bulk", summary: "Add several pantry items at once, merging quantities into existing matching items", security, request: { body: { content: { "application/json": { schema: z.object({ items: z.array(z.object({ rawName: z.string().min(1).max(200), quantity: z.string().optional(), unit: z.string().max(50).optional() })).min(1).max(100) }) } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/pantry/scan/barcode", summary: "Look up a barcode via Open Food Facts to prefill a pantry item", description: "Rate-limited: 20 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ barcode: z.string().trim().min(4).max(32).regex(/^[0-9]+$/) }) } }, required: true } }, responses: { 200: { description: "Lookup result", content: { "application/json": { schema: z.object({ found: z.boolean(), rawName: z.string().optional(), quantity: z.string().optional(), unit: z.string().optional() }) } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } }, 502: { description: "Lookup service unavailable", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/pantry/scan/photo", summary: "Identify pantry items from a photo using AI vision", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ imageBase64: z.string().max(14_000_000), mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]) }) } }, required: true } }, responses: { 200: { description: "Detected items", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });