From 93936eae1022ae55e30a76c4363b63c3f9c61332 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Fri, 24 Jul 2026 15:13:33 +0200 Subject: [PATCH] feat: pantry notes/categories, ingredient-alias matching, cook-log edit/delete, fork-list popover (v0.83.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 12 + FEATURE_AUDIT.md | 10 +- apps/web/app/(app)/pantry/page.tsx | 8 +- apps/web/app/(app)/recipes/[id]/page.tsx | 36 +- apps/web/app/(app)/recipes/can-cook/page.tsx | 6 +- apps/web/app/api/v1/pantry/[id]/route.ts | 11 +- apps/web/app/api/v1/pantry/bulk/route.ts | 21 +- .../api/v1/pantry/merge-duplicates/route.ts | 80 + apps/web/app/api/v1/pantry/route.ts | 7 + .../v1/recipes/[id]/cooked/[logId]/route.ts | 52 + .../app/api/v1/recipes/[id]/cooked/route.ts | 30 +- apps/web/app/api/v1/shopping-lists/route.ts | 8 +- apps/web/app/print/pantry/page.tsx | 3 +- .../web/app/print/shopping-list/[id]/page.tsx | 3 +- .../components/meal-plan/pantry-manager.tsx | 148 +- .../meal-plan/shopping-list-view.tsx | 4 +- .../components/pantry/pantry-item-dialog.tsx | 148 + .../recipe/edit-cook-log-dialog.tsx | 103 + .../components/recipe/forked-by-popover.tsx | 38 + .../components/recipe/mark-cooked-dialog.tsx | 5 +- .../components/recipe/mark-cooked-section.tsx | 147 +- apps/web/lib/changelog.ts | 16 +- apps/web/lib/ingredient-match.ts | 51 + apps/web/lib/markdown/pantry.ts | 4 +- apps/web/lib/markdown/shopping-list.ts | 4 +- apps/web/lib/openapi.ts | 13 +- apps/web/lib/pantry-match.ts | 19 +- apps/web/lib/pantry-shopping-match.ts | 21 +- apps/web/messages/en.json | 26 +- apps/web/messages/fr.json | 26 +- apps/web/package.json | 2 +- package.json | 2 +- .../db/src/migrations/0066_short_klaw.sql | 2 + .../db/src/migrations/meta/0066_snapshot.json | 6260 +++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/meal-planning.ts | 6 + packages/db/src/seed.ts | 33 +- 37 files changed, 7255 insertions(+), 117 deletions(-) create mode 100644 apps/web/app/api/v1/pantry/merge-duplicates/route.ts create mode 100644 apps/web/app/api/v1/recipes/[id]/cooked/[logId]/route.ts create mode 100644 apps/web/components/pantry/pantry-item-dialog.tsx create mode 100644 apps/web/components/recipe/edit-cook-log-dialog.tsx create mode 100644 apps/web/components/recipe/forked-by-popover.tsx create mode 100644 apps/web/lib/ingredient-match.ts create mode 100644 packages/db/src/migrations/0066_short_klaw.sql create mode 100644 packages/db/src/migrations/meta/0066_snapshot.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c91c1..7d71722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.83.0 — 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. + ## 0.82.0 — 2026-07-24 17:45 ### Added diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md index bb1efe6..da1bf1e 100644 --- a/FEATURE_AUDIT.md +++ b/FEATURE_AUDIT.md @@ -17,7 +17,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real | Feature | Status | Description | Key files | |---|---|---|---| | Recipe CRUD | Exists | Create/get/list/update/delete; update snapshots the prior version first (`recipeSnapshots`) | `apps/web/app/api/v1/recipes/**` | -| Fork / duplicate | Exists | Same backend action for both — UI just swaps the label based on ownership. Backlink works both directions: a forked recipe already showed "Forked from X"; the original now also shows "Forked by N others" with links to each — filtered to forks that are public/unlisted or belong to the viewer, so a private fork's existence/title never leaks to other viewers. Uses the same `recipeVariations` table as AI variations/adapt, not a separate fork-tracking mechanism. | `apps/web/app/api/v1/recipes/[id]/fork/route.ts`, `apps/web/app/(app)/recipes/[id]/page.tsx` | +| Fork / duplicate | Exists | Same backend action for both — UI just swaps the label based on ownership. Backlink works both directions: a forked recipe already showed "Forked from X"; the original now also shows "Forked by N others" as a click-to-open popover (list stays out of the page flow instead of a potentially long inline row) — filtered to forks that are public/unlisted or belong to the viewer, so a private fork's existence/title never leaks to other viewers. Uses the same `recipeVariations` table as AI variations/adapt, not a separate fork-tracking mechanism. | `apps/web/app/api/v1/recipes/[id]/fork/route.ts`, `apps/web/app/(app)/recipes/[id]/page.tsx`, `apps/web/components/recipe/forked-by-popover.tsx` | | Version history | Exists | `GET /recipes/[id]/versions`, diff-able snapshots | `apps/web/app/api/v1/recipes/[id]/versions/**` | | **Import from URL** | **Exists** | Fetches a page (SSRF-checked), strips markup, extracts a structured recipe via AI. Returns the extraction to the client — doesn't self-persist, caller POSTs it to create | `apps/web/app/api/v1/ai/import-url` | | Import from photo | Exists | Two-stage vision→text; recognizes a photographed page/handwritten recipe, self-persists as a private recipe | `apps/web/app/api/v1/ai/import-photo`, `lib/ai/features/{recognize-photo,generate-recipe-from-recognition}.ts` | @@ -69,8 +69,12 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real | Aisle categorization | Exists | Heuristic auto-assign + bulk re-categorize, plus inline custom-category creation and full drag-and-drop reordering (richer UI than previously documented) | `apps/web/lib/grocery-categories.ts` | | Grocery delivery integration | **Partial / stub** | Generic export payload works (integrator-shaped — no visible in-app UI consumer besides the Instacart adapter); Instacart adapter is an explicit stub (requires a partnership agreement Epicure doesn't have — returns 501 if unconfigured, throws if "configured") | `apps/web/lib/grocery-providers/instacart.ts` | | Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — | -| Pantry manual CRUD | Exists | Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow), undocumented until this pass | `apps/web/app/api/v1/pantry/**` | -| Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` | +| Pantry manual CRUD | Exists (extended 2026-07-24) | Full edit dialog added (previously add+delete only, despite the API already supporting `PUT`) — name/quantity/unit/expiry plus two new fields, **notes** (free text) and **category** (same `GROCERY_CATEGORIES` slugs shopping lists use). List now groups into collapsible-by-category sections when more than one category is present, mirroring the shopping list's grouping UX (without the drag-reorder — pantry has no manual ordering need). Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow). | `apps/web/app/api/v1/pantry/**`, `apps/web/components/meal-plan/pantry-manager.tsx`, `apps/web/components/pantry/pantry-item-dialog.tsx` | +| Ingredient alias matching (new 2026-07-24) | Exists | The `ingredients` table (canonical name + `aliases[]`) existed but was never populated or queried anywhere. Seeded with ~10 bilingual EN/FR staples (salt, sugar, pepper, flour, butter, milk, egg, onion, garlic, olive oil — `packages/db/src/seed.ts`) and wired into every place that compares ingredient names by raw text: pantry add/edit (sets `ingredientId` when a name/alias matches), the can-cook / "use it up soon" scorer, auto-deduct-on-cook, and shopping-list pantry-awareness on generation. Resolution happens at compare-time from free text (`resolveIngredientKey`), not from a stored FK on both sides — recipe ingredients still don't carry `ingredientId`. | `apps/web/lib/ingredient-match.ts`, `packages/db/src/seed.ts` | +| Merge duplicate pantry items (new 2026-07-24) | Exists | One-shot cleanup for pre-existing pantry rows that are the same ingredient under different names (added before alias matching existed) — groups by resolved ingredient key + normalized unit, sums quantities only when every row in a group has a parseable one (otherwise keeps the first known amount rather than guessing), concatenates notes, keeps the soonest expiry. Manual trigger (a button in the pantry toolbar), not automatic — manual single-item add still always inserts a new row rather than silently merging, since two batches of the same ingredient can have different expiry dates worth tracking separately. | `apps/web/app/api/v1/pantry/merge-duplicates/route.ts` | +| Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. Matching now goes through the ingredient-alias resolver, not a raw name string compare. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` | +| Cook log edit/delete (new 2026-07-24) | Exists | Plain (non-batch) cook-log entries can now be listed, edited (date/servings/notes), and removed — previously log-once, no way to fix a mistake or remove a duplicate entry. The recipe page's "Cooked N times" text is a hover tooltip (up to 8 dates, "+N more" beyond) that also opens a full manage sheet on click. Editing/deleting never touches pantry quantities — a deduction from when the entry was created isn't reversed or reapplied. | `apps/web/app/api/v1/recipes/[id]/cooked/[logId]/route.ts`, `apps/web/components/recipe/{mark-cooked-section,edit-cook-log-dialog}.tsx` | +| Quantity display precision (closed 2026-07-24) | Exists | Pantry and shopping-list quantities are stored as `decimal(10,4)` and were displayed as the raw string (e.g. "0.3333 kg", "2.0000 kg") everywhere: in-app lists, print views, and Markdown exports. All 6 spots now go through `formatQuantity` (fraction-aware rounding, already used for recipe ingredient scaling) instead of the raw column value. | `apps/web/lib/fractions.ts`, `apps/web/components/meal-plan/{pantry-manager,shopping-list-view}.tsx`, `apps/web/app/print/{pantry,shopping-list/[id]}/page.tsx`, `apps/web/lib/markdown/{pantry,shopping-list}.ts` | | Mark recipe as cooked (any recipe, not just batch-cook) | Exists (new, 2026-07-24) | A recipe can be logged as cooked any number of times — each log is a new `cookingHistory` row, no unique constraint, this was already true at the schema level, just not exposed for plain (non-batch) recipes before. New dialog on the recipe page: date (defaults today, can backdate), servings, and a deduct-from-pantry toggle (default on). Shows a "cooked N times · last DATE" indicator once logged. | `apps/web/components/recipe/{mark-cooked-dialog,mark-cooked-section}.tsx`, `apps/web/app/api/v1/recipes/[id]/cooked/route.ts` (`cookedAt` field, new) | | Billing/invoice details (full name, address, phone) | Exists (new, 2026-07-24) | New `userBillingDetails` table (1:1 with `users`), separate from the display `name` field. `fullName` is required by the form/API but nullable at the DB level (no backfill for existing users); address lines/city/postal code/country/phone are all optional. Lives under `Settings → Billing`, feeds future invoice generation — not wired into Stripe yet. | `packages/db/src/schema/billing.ts` (`userBillingDetails`), `apps/web/app/api/v1/users/me/billing-details/route.ts`, `apps/web/components/settings/billing-details-form.tsx` | | Post-signup onboarding wizard | Exists (new, 2026-07-24) | 3-step wizard (welcome → dietary/allergen prefs → notification opt-in) shown once per account, right after the app shell layout loads for a user with no `onboardingCompletedAt`. Skippable at every step; existing accounts were backfilled as already-onboarded so the wizard only appears for new signups. Also closes a real pre-existing gap: `userAllergens` had a table and a GDPR-export read, but **no write path at all** — this ships the first one. Dietary tags are a new `users.dietaryTags` jsonb column (mirrors `recipes.dietaryTags`'s 7-tag shape); neither is yet consumed by AI generation or recipe matching. | `apps/web/app/onboarding/page.tsx`, `apps/web/components/onboarding/onboarding-wizard.tsx`, `apps/web/app/api/v1/users/me/onboarding/route.ts`, `apps/web/app/(app)/layout.tsx` (redirect gate) | diff --git a/apps/web/app/(app)/pantry/page.tsx b/apps/web/app/(app)/pantry/page.tsx index 04de42a..b93dcaa 100644 --- a/apps/web/app/(app)/pantry/page.tsx +++ b/apps/web/app/(app)/pantry/page.tsx @@ -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) => { diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index 0acc398..ccfe7c2 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -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) { )} {visibleForks.length > 0 && ( -
-

- {visibleForks.length === 1 + -

- {visibleForks.map((f) => ( - - {f.child.title} - - ))} -
-
+ : formatMessage(m.recipe.forkedByCountPlural, { count: visibleForks.length }) + } + forks={visibleForks.map((f) => ({ id: f.child.id, title: f.child.title }))} + /> )}
@@ -563,8 +554,7 @@ export default async function RecipePage({ params }: Params) { )} diff --git a/apps/web/app/(app)/recipes/can-cook/page.tsx b/apps/web/app/(app)/recipes/can-cook/page.tsx index 5243a6c..5dc722a 100644 --- a/apps/web/app/(app)/recipes/can-cook/page.tsx +++ b/apps/web/app/(app)/recipes/can-cook/page.tsx @@ -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, diff --git a/apps/web/app/api/v1/pantry/[id]/route.ts b/apps/web/app/api/v1/pantry/[id]/route.ts index 04beaae..0641b09 100644 --- a/apps/web/app/api/v1/pantry/[id]/route.ts +++ b/apps/web/app/api/v1/pantry/[id]/route.ts @@ -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)); diff --git a/apps/web/app/api/v1/pantry/bulk/route.ts b/apps/web/app/api/v1/pantry/bulk/route.ts index a27cc4e..de9945a 100644 --- a/apps/web/app/api/v1/pantry/bulk/route.ts +++ b/apps/web/app/api/v1/pantry/bulk/route.ts @@ -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() }); } } diff --git a/apps/web/app/api/v1/pantry/merge-duplicates/route.ts b/apps/web/app/api/v1/pantry/merge-duplicates/route.ts new file mode 100644 index 0000000..490585c --- /dev/null +++ b/apps/web/app/api/v1/pantry/merge-duplicates/route.ts @@ -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(); + 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 }); +} diff --git a/apps/web/app/api/v1/pantry/route.ts b/apps/web/app/api/v1/pantry/route.ts index 714fac4..86f0d02 100644 --- a/apps/web/app/api/v1/pantry/route.ts +++ b/apps/web/app/api/v1/pantry/route.ts @@ -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, }); diff --git a/apps/web/app/api/v1/recipes/[id]/cooked/[logId]/route.ts b/apps/web/app/api/v1/recipes/[id]/cooked/[logId]/route.ts new file mode 100644 index 0000000..c8e3702 --- /dev/null +++ b/apps/web/app/api/v1/recipes/[id]/cooked/[logId]/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/v1/recipes/[id]/cooked/route.ts b/apps/web/app/api/v1/recipes/[id]/cooked/route.ts index 277f470..035582d 100644 --- a/apps/web/app/api/v1/recipes/[id]/cooked/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/cooked/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/shopping-lists/route.ts b/apps/web/app/api/v1/shopping-lists/route.ts index 86f6e6a..3b83105 100644 --- a/apps/web/app/api/v1/shopping-lists/route.ts +++ b/apps/web/app/api/v1/shopping-lists/route.ts @@ -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); } } } diff --git a/apps/web/app/print/pantry/page.tsx b/apps/web/app/print/pantry/page.tsx index 30a745e..9b38bcb 100644 --- a/apps/web/app/print/pantry/page.tsx +++ b/apps/web/app/print/pantry/page.tsx @@ -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 ( {item.rawName} - {[item.quantity, item.unit].filter(Boolean).join(" ") || "—"} + {[hasQuantity(item.quantity) ? formatQuantity(parseFloat(item.quantity!)) : null, item.unit].filter(Boolean).join(" ") || "—"} {exp ? daysLeft !== null && daysLeft < 0 diff --git a/apps/web/app/print/shopping-list/[id]/page.tsx b/apps/web/app/print/shopping-list/[id]/page.tsx index 3c9bd03..ea71929 100644 --- a/apps/web/app/print/shopping-list/[id]/page.tsx +++ b/apps/web/app/print/shopping-list/[id]/page.tsx @@ -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) {
  • - {[item.quantity, item.unit].filter(Boolean).join(" ")} + {[hasQuantity(item.quantity) ? formatQuantity(parseFloat(item.quantity!)) : null, item.unit].filter(Boolean).join(" ")} {item.rawName}
  • diff --git a/apps/web/components/meal-plan/pantry-manager.tsx b/apps/web/components/meal-plan/pantry-manager.tsx index 9f76660..d8a585a 100644 --- a/apps/web/components/meal-plan/pantry-manager.tsx +++ b/apps/web/components/meal-plan/pantry-manager.tsx @@ -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(initialItems); @@ -44,6 +42,23 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) const [expiresAt, setExpiresAt] = useState(""); const [adding, setAdding] = useState(false); const [confirmId, setConfirmId] = useState(null); + const [editingItem, setEditingItem] = useState(null); + const [collapsed, setCollapsed] = useState>(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(); + 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 (
    @@ -95,33 +137,67 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) {t("add")} router.refresh()} /> +
    {/* Item list */} {items.length === 0 ? ( ) : ( -
    - {sorted.map((item) => { - const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null; - const expiring = days !== null && days <= 3; - const expired = days !== null && days < 0; +
    + {sortedGroupKeys.map((key) => { + const groupItems = grouped.get(key)!; + const isCollapsed = collapsed.has(key); return ( -
    -
    -
    - {item.rawName} - {item.quantity && {item.quantity}{item.unit ? ` ${item.unit}` : ""}} - {expired && {t("expired")}} - {expiring && !expired && {t("expiresInDays", { days })}} +
    + {showGroupHeaders && ( + + )} + {!isCollapsed && ( +
    + {groupItems.map((item) => { + const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null; + const expiring = days !== null && days <= 3; + const expired = days !== null && days < 0; + return ( +
    +
    +
    + {item.rawName} + {hasQuantity(item.quantity) && ( + {formatQuantity(parseFloat(item.quantity!))}{item.unit ? ` ${item.unit}` : ""} + )} + {expired && {t("expired")}} + {expiring && !expired && {t("expiresInDays", { days })}} +
    + {item.expiresAt && !expired && !expiring && ( +

    {t("expiresOn", { date: new Date(item.expiresAt).toLocaleDateString() })}

    + )} + {item.notes &&

    {item.notes}

    } +
    + + +
    + ); + })}
    - {item.expiresAt && !expired && !expiring && ( -

    {t("expiresOn", { date: new Date(item.expiresAt).toLocaleDateString() })}

    - )} -
    - + )}
    ); })} @@ -150,6 +226,18 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) + + {editingItem && ( + !open && setEditingItem(null)} + onSaved={(updated) => { + setItems((prev) => prev.map((i) => (i.id === updated.id ? updated : i))); + setEditingItem(null); + }} + /> + )}
    ); } diff --git a/apps/web/components/meal-plan/shopping-list-view.tsx b/apps/web/components/meal-plan/shopping-list-view.tsx index 10a3aaa..2254470 100644 --- a/apps/web/components/meal-plan/shopping-list-view.tsx +++ b/apps/web/components/meal-plan/shopping-list-view.tsx @@ -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) && ( - {hasQuantity(item.quantity) ? item.quantity : ""}{item.unit ? ` ${item.unit}` : ""} + {hasQuantity(item.quantity) ? formatQuantity(parseFloat(item.quantity!)) : ""}{item.unit ? ` ${item.unit}` : ""} )} diff --git a/apps/web/components/pantry/pantry-item-dialog.tsx b/apps/web/components/pantry/pantry-item-dialog.tsx new file mode 100644 index 0000000..b974040 --- /dev/null +++ b/apps/web/components/pantry/pantry-item-dialog.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { GROCERY_CATEGORIES } from "@/lib/grocery-categories"; + +export type PantryItem = { + id: string; + rawName: string; + quantity: string | null; + unit: string | null; + notes: string | null; + aisle: string | null; + expiresAt: string | null; +}; + +const OTHER_VALUE = "__other__"; + +export function PantryItemDialog({ + item, + open, + onOpenChange, + onSaved, +}: { + item: PantryItem; + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved: (updated: PantryItem) => void; +}) { + const t = useTranslations("pantry"); + const tShopping = useTranslations("shoppingLists"); + const tCommon = useTranslations("common"); + const [rawName, setRawName] = useState(item.rawName); + const [quantity, setQuantity] = useState(item.quantity ?? ""); + const [unit, setUnit] = useState(item.unit ?? ""); + const [aisle, setAisle] = useState(item.aisle ?? OTHER_VALUE); + const [notes, setNotes] = useState(item.notes ?? ""); + const [expiresAt, setExpiresAt] = useState(item.expiresAt ? item.expiresAt.slice(0, 10) : ""); + const [saving, setSaving] = useState(false); + + async function handleSave() { + if (!rawName.trim()) return; + setSaving(true); + try { + const res = await fetch(`/api/v1/pantry/${item.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + rawName: rawName.trim(), + quantity: quantity.trim() || null, + unit: unit.trim() || null, + aisle: aisle === OTHER_VALUE ? null : aisle, + notes: notes.trim() || null, + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, + }), + }); + if (!res.ok) { toast.error(t("editFailed")); return; } + toast.success(t("editSaved")); + onSaved({ + id: item.id, + rawName: rawName.trim(), + quantity: quantity.trim() || null, + unit: unit.trim() || null, + aisle: aisle === OTHER_VALUE ? null : aisle, + notes: notes.trim() || null, + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, + }); + onOpenChange(false); + } finally { + setSaving(false); + } + } + + return ( + + + + {t("editDialogTitle")} + +
    +
    + + setRawName(e.target.value)} /> +
    +
    +
    + + setQuantity(e.target.value)} /> +
    +
    + + setUnit(e.target.value)} /> +
    +
    +
    + + +
    +
    + + setExpiresAt(e.target.value)} /> +
    +
    + +