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:
@@ -11,6 +11,7 @@ import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
|
||||
import { dishExpiresAt, daysUntil, isLeftoverExpiringSoon } from "@/lib/leftover-match";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags";
|
||||
import { loadIngredientAliasIndex } from "@/lib/ingredient-match";
|
||||
|
||||
export const metadata: Metadata = {};
|
||||
|
||||
@@ -22,7 +23,7 @@ export default async function PantryPage() {
|
||||
const markdownExportLocked = !featureFlags.markdown_export[viewerTier];
|
||||
const markdownExportAvailable = isFeatureAvailableAnyTier(featureFlags, "markdown_export");
|
||||
|
||||
const [items, candidateRecipes, cookedDishes] = await Promise.all([
|
||||
const [items, candidateRecipes, cookedDishes, aliasIndex] = await Promise.all([
|
||||
db.query.pantryItems.findMany({
|
||||
where: eq(pantryItems.userId, session.user.id),
|
||||
orderBy: asc(pantryItems.rawName),
|
||||
@@ -38,6 +39,7 @@ export default async function PantryPage() {
|
||||
recipe: { columns: { id: true, title: true } },
|
||||
},
|
||||
}),
|
||||
loadIngredientAliasIndex(),
|
||||
]);
|
||||
|
||||
const mappedItems = items.map((i) => ({
|
||||
@@ -45,10 +47,12 @@ export default async function PantryPage() {
|
||||
rawName: i.rawName,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
notes: i.notes,
|
||||
aisle: i.aisle,
|
||||
expiresAt: i.expiresAt?.toISOString() ?? null,
|
||||
}));
|
||||
|
||||
const suggestions = scoreRecipesAgainstPantry(candidateRecipes, items)
|
||||
const suggestions = scoreRecipesAgainstPantry(candidateRecipes, items, aliasIndex)
|
||||
.filter((s) => s.usesExpiring.length > 0)
|
||||
.slice(0, 3)
|
||||
.map((s) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PrintButton } from "@/components/recipe/print-button";
|
||||
import { ShareRecipeButton } from "@/components/recipe/share-recipe-button";
|
||||
import { SaveOfflineButton } from "@/components/recipe/save-offline-button";
|
||||
import { VersionHistoryButton } from "@/components/recipe/version-history-button";
|
||||
import { ForkedByPopover } from "@/components/recipe/forked-by-popover";
|
||||
import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button";
|
||||
import { ForkRecipeButton } from "@/components/recipe/fork-recipe-button";
|
||||
import { NutritionPanel } from "@/components/recipe/nutrition-panel";
|
||||
@@ -109,7 +110,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
db.query.cookingHistory.findMany({
|
||||
where: and(eq(cookingHistory.recipeId, id), eq(cookingHistory.userId, session.user.id)),
|
||||
orderBy: desc(cookingHistory.cookedAt),
|
||||
columns: { batchDishId: true, cookedAt: true },
|
||||
columns: { id: true, batchDishId: true, cookedAt: true, servings: true, notes: true },
|
||||
}),
|
||||
getFeatureFlagMatrix(),
|
||||
getFeaturePrefs(session.user.id),
|
||||
@@ -156,9 +157,9 @@ export default async function RecipePage({ params }: Params) {
|
||||
|
||||
// Non-batch cook log — batch-cook recipes track this per-dish instead
|
||||
// (dishCookedAtMap above), logged via BatchCookDishes, not this list.
|
||||
const plainCookLog = dishCookLog.filter((l) => !l.batchDishId);
|
||||
const cookCount = plainCookLog.length;
|
||||
const lastCookedAt = plainCookLog[0]?.cookedAt.toISOString() ?? null;
|
||||
const plainCookLog = dishCookLog
|
||||
.filter((l) => !l.batchDishId)
|
||||
.map((l) => ({ id: l.id, cookedAt: l.cookedAt.toISOString(), servings: l.servings, notes: l.notes }));
|
||||
|
||||
const avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null;
|
||||
const ratingCount = ratingData[0]?.total ?? 0;
|
||||
@@ -209,24 +210,14 @@ export default async function RecipePage({ params }: Params) {
|
||||
</Link>
|
||||
)}
|
||||
{visibleForks.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>
|
||||
{visibleForks.length === 1
|
||||
<ForkedByPopover
|
||||
label={
|
||||
visibleForks.length === 1
|
||||
? m.recipe.forkedByCountSingular
|
||||
: formatMessage(m.recipe.forkedByCountPlural, { count: visibleForks.length })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1">
|
||||
{visibleForks.map((f) => (
|
||||
<Link
|
||||
key={f.child.id}
|
||||
href={`/recipes/${f.child.id}`}
|
||||
className="hover:text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
{f.child.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
: formatMessage(m.recipe.forkedByCountPlural, { count: visibleForks.length })
|
||||
}
|
||||
forks={visibleForks.map((f) => ({ id: f.child.id, title: f.child.title }))}
|
||||
/>
|
||||
)}
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||||
@@ -563,8 +554,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
<MarkCookedSection
|
||||
recipeId={id}
|
||||
baseServings={recipe.baseServings}
|
||||
cookCount={cookCount}
|
||||
lastCookedAt={lastCookedAt}
|
||||
initialLogs={plainCookLog}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { eq } from "@epicure/db";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
import { CanCookContent } from "@/components/recipe/can-cook-content";
|
||||
import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
|
||||
import { loadIngredientAliasIndex } from "@/lib/ingredient-match";
|
||||
|
||||
export const metadata: Metadata = {};
|
||||
|
||||
@@ -13,7 +14,7 @@ export default async function CanCookPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const [userRecipes, pantry] = await Promise.all([
|
||||
const [userRecipes, pantry, aliasIndex] = await Promise.all([
|
||||
db.query.recipes.findMany({
|
||||
where: eq(recipes.authorId, session.user.id),
|
||||
with: {
|
||||
@@ -24,9 +25,10 @@ export default async function CanCookPage() {
|
||||
db.query.pantryItems.findMany({
|
||||
where: eq(pantryItems.userId, session.user.id),
|
||||
}),
|
||||
loadIngredientAliasIndex(),
|
||||
]);
|
||||
|
||||
const scored = scoreRecipesAgainstPantry(userRecipes, pantry).map((s) => {
|
||||
const scored = scoreRecipesAgainstPantry(userRecipes, pantry, aliasIndex).map((s) => {
|
||||
const cover = s.recipe.photos?.find((p) => p.isCover) ?? s.recipe.photos?.[0];
|
||||
return {
|
||||
...s,
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, cookingHistory, eq, and, isNull } from "@epicure/db";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
|
||||
type Params = { params: Promise<{ id: string; logId: string }> };
|
||||
|
||||
const PatchSchema = z.object({
|
||||
servings: z.number().int().min(1).max(1000).nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
cookedAt: z.string().optional(),
|
||||
});
|
||||
|
||||
// Plain (non-batch) cook log entries only — see the sibling GET's comment.
|
||||
// Editing/deleting never touches pantry quantities: the deduction (if any)
|
||||
// already happened at creation time and isn't reversible from here.
|
||||
export async function PATCH(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSessionOrApiKey(req);
|
||||
if (response) return response;
|
||||
const { id, logId } = await params;
|
||||
|
||||
const log = await db.query.cookingHistory.findFirst({
|
||||
where: and(eq(cookingHistory.id, logId), eq(cookingHistory.recipeId, id), eq(cookingHistory.userId, session!.user.id), isNull(cookingHistory.batchDishId)),
|
||||
});
|
||||
if (!log) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const parsed = PatchSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
const data = parsed.data;
|
||||
const cookedAt = data.cookedAt ? new Date(data.cookedAt) : undefined;
|
||||
|
||||
await db.update(cookingHistory).set({
|
||||
...(data.servings !== undefined && { servings: data.servings ?? undefined }),
|
||||
...(data.notes !== undefined && { notes: data.notes ?? undefined }),
|
||||
...(cookedAt && !isNaN(cookedAt.getTime()) && { cookedAt }),
|
||||
}).where(eq(cookingHistory.id, logId));
|
||||
|
||||
return NextResponse.json({ updated: true });
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSessionOrApiKey(req);
|
||||
if (response) return response;
|
||||
const { id, logId } = await params;
|
||||
|
||||
await db.delete(cookingHistory).where(
|
||||
and(eq(cookingHistory.id, logId), eq(cookingHistory.recipeId, id), eq(cookingHistory.userId, session!.user.id), isNull(cookingHistory.batchDishId))
|
||||
);
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
@@ -1,10 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, recipes, cookingHistory, pantryItems, recipeIngredients, recipeBatchDishes, eq, and } from "@epicure/db";
|
||||
import { db, recipes, cookingHistory, pantryItems, recipeIngredients, recipeBatchDishes, eq, and, desc, isNull } from "@epicure/db";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
import { loadIngredientAliasIndex, resolveIngredientKey } from "@/lib/ingredient-match";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
// Plain (non-batch) cook log entries only — batch-cook dishes have their
|
||||
// own per-dish "cooked" indicator (dishCookedAtMap in the recipe page) and
|
||||
// aren't meant to be edited/removed one at a time here.
|
||||
export async function GET(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSessionOrApiKey(req);
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
|
||||
const logs = await db.query.cookingHistory.findMany({
|
||||
where: and(eq(cookingHistory.recipeId, id), eq(cookingHistory.userId, session!.user.id), isNull(cookingHistory.batchDishId)),
|
||||
orderBy: desc(cookingHistory.cookedAt),
|
||||
columns: { id: true, cookedAt: true, servings: true, notes: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: logs });
|
||||
}
|
||||
|
||||
const Schema = z.object({
|
||||
servings: z.number().int().min(1).max(1000).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
@@ -48,9 +66,10 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
}
|
||||
|
||||
const cookedAt = data.cookedAt ? new Date(data.cookedAt) : new Date();
|
||||
const logId = crypto.randomUUID();
|
||||
|
||||
await db.insert(cookingHistory).values({
|
||||
id: crypto.randomUUID(),
|
||||
id: logId,
|
||||
userId,
|
||||
recipeId: id,
|
||||
batchDishId: data.batchDishId,
|
||||
@@ -70,11 +89,12 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
const userPantry = await db.query.pantryItems.findMany({
|
||||
where: eq(pantryItems.userId, userId),
|
||||
});
|
||||
const aliasIndex = await loadIngredientAliasIndex();
|
||||
|
||||
for (const ing of ings) {
|
||||
const key = ing.rawName.toLowerCase();
|
||||
const key = resolveIngredientKey(ing.rawName, aliasIndex);
|
||||
const pantryItem = userPantry.find(
|
||||
(p) => p.rawName.toLowerCase() === key && (p.unit ?? "") === (ing.unit ?? "")
|
||||
(p) => resolveIngredientKey(p.rawName, aliasIndex) === key && (p.unit ?? "") === (ing.unit ?? "")
|
||||
);
|
||||
if (!pantryItem) continue;
|
||||
|
||||
@@ -97,5 +117,5 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ logged: true }, { status: 201 });
|
||||
return NextResponse.json({ logged: true, id: logId }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { db, shoppingLists, shoppingListItems, mealPlans, mealPlanEntries, recip
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
import { applyPantryToItems, mergeIngredients } from "@/lib/pantry-shopping-match";
|
||||
import { guessAisle } from "@/lib/grocery-categories";
|
||||
import { loadIngredientAliasIndex } from "@/lib/ingredient-match";
|
||||
|
||||
const CreateSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
@@ -63,8 +64,11 @@ export async function POST(req: NextRequest) {
|
||||
// Reduce/flag quantities already covered by the user's pantry. Conservative: never silently
|
||||
// drops an item — fully-covered items are still inserted, flagged `inPantry`, so nothing
|
||||
// disappears from view without the user seeing it.
|
||||
const pantry = await db.query.pantryItems.findMany({ where: eq(pantryItems.userId, session!.user.id) });
|
||||
items = applyPantryToItems(mergedItems, pantry);
|
||||
const [pantry, aliasIndex] = await Promise.all([
|
||||
db.query.pantryItems.findMany({ where: eq(pantryItems.userId, session!.user.id) }),
|
||||
loadIngredientAliasIndex(),
|
||||
]);
|
||||
items = applyPantryToItems(mergedItems, pantry, aliasIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { auth } from "@/lib/auth/server";
|
||||
import { db, pantryItems, eq, asc } from "@epicure/db";
|
||||
import { PrintTrigger } from "@/components/recipe/print-trigger";
|
||||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||||
import { formatQuantity, hasQuantity } from "@/lib/fractions";
|
||||
|
||||
export default async function PantryPrintPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
@@ -73,7 +74,7 @@ export default async function PantryPrintPage() {
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>{item.rawName}</td>
|
||||
<td>{[item.quantity, item.unit].filter(Boolean).join(" ") || "—"}</td>
|
||||
<td>{[hasQuantity(item.quantity) ? formatQuantity(parseFloat(item.quantity!)) : null, item.unit].filter(Boolean).join(" ") || "—"}</td>
|
||||
<td className={expiryClass}>
|
||||
{exp
|
||||
? daysLeft !== null && daysLeft < 0
|
||||
|
||||
@@ -5,6 +5,7 @@ import { auth } from "@/lib/auth/server";
|
||||
import { db, shoppingLists, eq, and } from "@epicure/db";
|
||||
import { PrintTrigger } from "@/components/recipe/print-trigger";
|
||||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||||
import { formatQuantity, hasQuantity } from "@/lib/fractions";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -109,7 +110,7 @@ export default async function ShoppingListPrintPage({ params }: Params) {
|
||||
<li key={item.id} className={item.checked ? "checked" : ""}>
|
||||
<span className="check" />
|
||||
<span className="qty">
|
||||
{[item.quantity, item.unit].filter(Boolean).join(" ")}
|
||||
{[hasQuantity(item.quantity) ? formatQuantity(parseFloat(item.quantity!)) : null, item.unit].filter(Boolean).join(" ")}
|
||||
</span>
|
||||
<span>{item.rawName}</span>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user