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
+15 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.82.0";
export const APP_VERSION = "0.83.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,20 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.83.0",
date: "2026-07-24 19:00",
added: [
"Pantry items can now have notes and a category, with the list grouped into collapsible category sections like the shopping list.",
"Ingredient-alias matching: pantry items, recipe ingredients, and shopping-list generation now recognize that \"sel\", \"sel fin\", and \"table salt\" are the same ingredient (seeded with ~10 common EN/FR staples) — improves can-cook scoring, auto-deduct-on-cook accuracy, and pantry-awareness when generating a shopping list.",
"A \"Merge duplicates\" button in the pantry cleans up items that turn out to be the same ingredient under a different name, summing quantities where possible.",
"Cook log entries (from \"Mark cooked\") can now be edited and deleted, not just created. The \"Cooked N times\" text is a hover tooltip listing every date, and opens a full history sheet on click.",
"The \"Forked by N others\" backlink on a recipe page is now a click-to-open popover instead of an inline list, so a heavily-forked recipe doesn't grow a long list directly on the page.",
],
fixed: [
"Pantry and shopping-list quantities were displayed with their full stored precision (e.g. \"0.3333 kg\", \"2.0000 kg\") everywhere — in-app, print views, and Markdown exports. Now rounded/fraction-formatted consistently with recipe ingredient display.",
],
},
{
version: "0.82.0",
date: "2026-07-24 17:45",
+51
View File
@@ -0,0 +1,51 @@
import { db, ingredients, sql } from "@epicure/db";
export type IngredientAliasIndex = Map<string, string>;
function normalize(name: string): string {
return name.trim().toLowerCase();
}
/**
* Loads every canonical ingredient's name + aliases into a flat
* lowercased-string -> canonical-ingredient-id map, once per request. Used
* to recognize that "sel", "sel fin", and "table salt" are all the same
* ingredient, without requiring every recipe/pantry row to carry a stored
* ingredientId (they don't — this resolves purely from the free-text name
* at comparison time).
*/
export async function loadIngredientAliasIndex(): Promise<IngredientAliasIndex> {
const rows = await db.select({ id: ingredients.id, name: ingredients.name, aliases: ingredients.aliases }).from(ingredients);
const index: IngredientAliasIndex = new Map();
for (const row of rows) {
index.set(normalize(row.name), row.id);
for (const alias of row.aliases) {
index.set(normalize(alias), row.id);
}
}
return index;
}
/** Canonical ingredient id if `rawName` matches a known name/alias exactly
* (case/whitespace-insensitive); otherwise the normalized rawName itself,
* so unmatched items still compare equal to other unmatched items with the
* exact same text (today's behavior, unchanged for anything not seeded). */
export function resolveIngredientKey(rawName: string, index: IngredientAliasIndex): string {
const normalized = normalize(rawName);
return index.get(normalized) ?? normalized;
}
/** Single-name lookup (pantry add/edit) — a direct query rather than
* loading the whole table, since this runs once per add/rename rather than
* in a loop. Returns null when there's no canonical match, meaning the item
* stays a plain freeform pantry entry. */
export async function findIngredientIdByName(rawName: string): Promise<string | null> {
const normalized = normalize(rawName);
if (!normalized) return null;
const [match] = await db
.select({ id: ingredients.id })
.from(ingredients)
.where(sql`lower(${ingredients.name}) = ${normalized} or exists (select 1 from unnest(${ingredients.aliases}) a where lower(a) = ${normalized})`)
.limit(1);
return match?.id ?? null;
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { formatQuantity, hasQuantity } from "@/lib/fractions";
type PantryMarkdownInput = {
items: Array<{ rawName: string; quantity: string | null; unit: string | null; expiresAt: string | null }>;
};
@@ -6,7 +8,7 @@ export function pantryToMarkdown(pantry: PantryMarkdownInput): string {
const lines: string[] = ["# Pantry", ""];
for (const item of pantry.items) {
const qty = [item.quantity, item.unit].filter(Boolean).join(" ");
const qty = [hasQuantity(item.quantity) ? formatQuantity(parseFloat(item.quantity!)) : null, item.unit].filter(Boolean).join(" ");
const expiry = item.expiresAt ? ` (expires ${new Date(item.expiresAt).toLocaleDateString()})` : "";
lines.push(`- ${qty ? `${qty} ` : ""}${item.rawName}${expiry}`);
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { formatQuantity, hasQuantity } from "@/lib/fractions";
type ShoppingListMarkdownInput = {
name: string;
items: Array<{ rawName: string; quantity: string | null; unit: string | null; aisle: string | null; checked: boolean }>;
@@ -17,7 +19,7 @@ export function shoppingListToMarkdown(list: ShoppingListMarkdownInput): string
for (const [aisle, items] of byAisle) {
lines.push(`## ${aisle}`, "");
for (const item of items) {
const qty = [item.quantity, item.unit].filter(Boolean).join(" ");
const qty = [hasQuantity(item.quantity) ? formatQuantity(parseFloat(item.quantity!)) : null, item.unit].filter(Boolean).join(" ");
lines.push(`- [${item.checked ? "x" : " "}] ${qty ? `${qty} ` : ""}${item.rawName}`);
}
lines.push("");
+9 -4
View File
@@ -213,7 +213,8 @@ export function generateOpenApiSpec(): object {
const PantryItemRef = registry.register("PantryItem", z.object({
id: z.string(), rawName: z.string(), quantity: z.string().nullable(),
unit: z.string().nullable(), expiresAt: z.string().datetime().nullable(),
unit: z.string().nullable(), notes: z.string().nullable(), aisle: z.string().nullable(),
expiresAt: z.string().datetime().nullable(),
}));
const ShoppingListRef = registry.register("ShoppingList", z.object({
@@ -290,7 +291,10 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "patch", path: "/api/v1/recipes/bulk", summary: "Bulk update visibility/tags (owned only)", security, request: { body: { content: { "application/json": { schema: BulkUpdateRecipesRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request / nothing to update", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/bulk/export", summary: "Export several recipes as one Markdown document", description: "Gated by the markdown_export tier feature flag.", security, request: { body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "Markdown", content: { "application/json": { schema: z.object({ markdown: z.string() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "None of the ids belong to you", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/fork", summary: "Fork (or duplicate your own) a recipe", description: "Rate-limited: 20 req/min. Source must be your own, public, or unlisted.", security, request: { params: idParam }, responses: { 201: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/cooked", summary: "Log that you cooked this recipe", description: "Optionally deducts matching ingredients from your pantry. A recipe can be logged as cooked any number of times — each call inserts a new history row, never updates one. cookedAt lets you backdate a cook instead of only logging \"now\".", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ servings: z.number().int().min(1).max(1000).optional(), notes: z.string().max(2000).optional(), deductFromPantry: z.boolean().default(true), batchDishId: z.string().optional(), cookedAt: z.string().optional().describe("ISO date (YYYY-MM-DD) or datetime; defaults to now") }) } } } }, responses: { 201: { description: "Logged", content: { "application/json": { schema: z.object({ logged: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/cooked", summary: "List your cook log for this recipe", description: "Plain (non-batch) entries only — batch-cook dishes have their own per-dish cooked indicator.", security, request: { params: idParam }, responses: { 200: { description: "Cook log, newest first", content: { "application/json": { schema: z.object({ data: z.array(z.object({ id: z.string(), cookedAt: z.string().datetime(), servings: z.number().int().nullable(), notes: z.string().nullable() })) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/cooked", summary: "Log that you cooked this recipe", description: "Optionally deducts matching ingredients from your pantry. A recipe can be logged as cooked any number of times — each call inserts a new history row, never updates one. cookedAt lets you backdate a cook instead of only logging \"now\".", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ servings: z.number().int().min(1).max(1000).optional(), notes: z.string().max(2000).optional(), deductFromPantry: z.boolean().default(true), batchDishId: z.string().optional(), cookedAt: z.string().optional().describe("ISO date (YYYY-MM-DD) or datetime; defaults to now") }) } } } }, responses: { 201: { description: "Logged", content: { "application/json": { schema: z.object({ logged: z.boolean(), id: z.string() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/recipes/{id}/cooked/{logId}", summary: "Edit a cook log entry", description: "Plain (non-batch) entries only. Never touches pantry quantities — any deduction from when this was logged is not reversed or reapplied.", security, request: { params: z.object({ id: z.string(), logId: z.string() }), body: { content: { "application/json": { schema: z.object({ servings: z.number().int().min(1).max(1000).nullable().optional(), notes: z.string().max(2000).nullable().optional(), cookedAt: z.string().optional() }) } } } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/recipes/{id}/cooked/{logId}", summary: "Remove a cook log entry", description: "Plain (non-batch) entries only. Never touches pantry quantities.", security, request: { params: z.object({ id: z.string(), logId: z.string() }) }, responses: { 204: { description: "Removed" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/nutrition", summary: "Get cached nutrition estimate or manually-entered values", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data or null", content: { "application/json": { schema: z.object({ nutrition: z.object({ perServing: NutritionInputRef }).nullable(), manual: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/nutrition", summary: "Compute a fresh AI nutrition estimate (author only)", description: "Rate-limited: 10 req/min. Consumes AI quota. Overwrites any manually-entered nutrition and clears nutritionManual. Gated by the nutrition_estimation tier feature flag.", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data", content: { "application/json": { schema: z.object({ nutrition: z.object({ perServing: NutritionInputRef }) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/notes", summary: "Get your own private note on a recipe", security, request: { params: idParam }, responses: { 200: { description: "Note or null", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
@@ -436,9 +440,10 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "post", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "Add/replace an entry on a shared plan (editor role required)", security, request: { params: z.object({ mealPlanId: z.string() }), body: { content: { "application/json": { schema: CreateMealPlanEntryRef.omit({ batchDishId: true }) } }, required: true } }, responses: { 201: { description: "Entry id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden (viewer role)", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found / recipe not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "Delete one or more entries on a shared plan (editor role required)", security, request: { params: z.object({ mealPlanId: z.string() }), query: z.object({ entryId: z.string().optional(), ids: z.string().optional().describe("comma-separated entry ids, for clearing a day/week") }) }, responses: { 204: { description: "Deleted" }, 400: { description: "entryId or ids required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/pantry", summary: "List pantry items", security, request: { query: z.object({ limit: z.coerce.number().int().min(1).max(100).default(50), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.object({ data: z.array(PantryItemRef), total: z.number().int(), limit: z.number().int(), offset: z.number().int() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/pantry", summary: "Add a pantry item", security, request: { body: { content: { "application/json": { schema: z.object({ rawName: z.string().min(1).max(200), quantity: z.string().optional(), unit: z.string().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(), 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: "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/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 } } } } });
+13 -6
View File
@@ -1,3 +1,5 @@
import { resolveIngredientKey, type IngredientAliasIndex } from "./ingredient-match";
export const EXPIRING_WITHIN_DAYS = 3;
export function isExpiringSoon(expiresAt: Date | null): boolean {
@@ -10,23 +12,28 @@ type ScorableRecipe<T> = T & { ingredients: { rawName: string }[] };
export function scoreRecipesAgainstPantry<T>(
recipesList: ScorableRecipe<T>[],
pantry: { rawName: string; expiresAt: Date | null }[]
pantry: { rawName: string; expiresAt: Date | null }[],
aliasIndex?: IngredientAliasIndex
) {
const pantryKeys = new Set(pantry.map((p) => p.rawName.toLowerCase()));
// With no alias index, this resolves to a plain lowercase compare —
// same behavior as before aliases existed.
const keyOf = (name: string) => (aliasIndex ? resolveIngredientKey(name, aliasIndex) : name.trim().toLowerCase());
const pantryKeys = new Set(pantry.map((p) => keyOf(p.rawName)));
const expiringSoonKeys = new Set(
pantry.filter((p) => isExpiringSoon(p.expiresAt)).map((p) => p.rawName.toLowerCase())
pantry.filter((p) => isExpiringSoon(p.expiresAt)).map((p) => keyOf(p.rawName))
);
return recipesList
.filter((r) => r.ingredients.length > 0)
.map((recipe) => {
const matched = recipe.ingredients.filter((ing) => pantryKeys.has(ing.rawName.toLowerCase())).length;
const matched = recipe.ingredients.filter((ing) => pantryKeys.has(keyOf(ing.rawName))).length;
const missing = recipe.ingredients
.filter((ing) => !pantryKeys.has(ing.rawName.toLowerCase()))
.filter((ing) => !pantryKeys.has(keyOf(ing.rawName)))
.map((ing) => ing.rawName)
.slice(0, 5);
const usesExpiring = recipe.ingredients
.filter((ing) => expiringSoonKeys.has(ing.rawName.toLowerCase()))
.filter((ing) => expiringSoonKeys.has(keyOf(ing.rawName)))
.map((ing) => ing.rawName);
const total = recipe.ingredients.length;
return { recipe, matched, total, pct: Math.round((matched / total) * 100), missing, usesExpiring };
+15 -6
View File
@@ -13,6 +13,7 @@
*/
import { extractIngredientQuantity } from "./extract-ingredient-quantity";
import { resolveIngredientKey, type IngredientAliasIndex } from "./ingredient-match";
export type PantrySourceItem = {
ingredientId: string | null;
@@ -55,7 +56,8 @@ export function formatQuantity(n: number): string {
*/
export function applyPantryToItems(
items: ShoppingSourceItem[],
pantry: PantrySourceItem[]
pantry: PantrySourceItem[],
aliasIndex?: IngredientAliasIndex
): PantryAdjustedItem[] {
const pantryByIngredientId = new Map<string, PantrySourceItem[]>();
const pantryByName = new Map<string, PantrySourceItem[]>();
@@ -66,10 +68,15 @@ export function applyPantryToItems(
list.push(p);
pantryByIngredientId.set(p.ingredientId, list);
}
const nameKey = normalizeName(p.rawName);
const list = pantryByName.get(nameKey) ?? [];
list.push(p);
pantryByName.set(nameKey, list);
// Index under both the plain normalized name and its alias-resolved
// canonical key (e.g. "sel fin" also indexes under salt's canonical
// id) — a shopping item written as "sel" then still finds it.
for (const key of new Set([normalizeName(p.rawName), aliasIndex ? resolveIngredientKey(p.rawName, aliasIndex) : null])) {
if (!key) continue;
const list = pantryByName.get(key) ?? [];
list.push(p);
pantryByName.set(key, list);
}
}
return items.map((item) => {
@@ -78,7 +85,9 @@ export function applyPantryToItems(
if (item.ingredientId && pantryByIngredientId.has(item.ingredientId)) {
matches = pantryByIngredientId.get(item.ingredientId);
} else {
matches = pantryByName.get(normalizeName(item.rawName));
matches =
pantryByName.get(normalizeName(item.rawName)) ??
(aliasIndex ? pantryByName.get(resolveIngredientKey(item.rawName, aliasIndex)) : undefined);
}
if (!matches || matches.length === 0) {