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
+12
View File
@@ -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. 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 ## 0.82.0 — 2026-07-24 17:45
### Added ### Added
+7 -3
View File
@@ -17,7 +17,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| Feature | Status | Description | Key files | | 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/**` | | 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/**` | | 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 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` | | 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` | | 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` | | 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 | — | | 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/**` | | 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` |
| 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` | | 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) | | 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` | | 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) | | 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) |
+6 -2
View File
@@ -11,6 +11,7 @@ import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
import { dishExpiresAt, daysUntil, isLeftoverExpiringSoon } from "@/lib/leftover-match"; import { dishExpiresAt, daysUntil, isLeftoverExpiringSoon } from "@/lib/leftover-match";
import { getPublicUrl } from "@/lib/storage"; import { getPublicUrl } from "@/lib/storage";
import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags"; import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags";
import { loadIngredientAliasIndex } from "@/lib/ingredient-match";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
@@ -22,7 +23,7 @@ export default async function PantryPage() {
const markdownExportLocked = !featureFlags.markdown_export[viewerTier]; const markdownExportLocked = !featureFlags.markdown_export[viewerTier];
const markdownExportAvailable = isFeatureAvailableAnyTier(featureFlags, "markdown_export"); 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({ db.query.pantryItems.findMany({
where: eq(pantryItems.userId, session.user.id), where: eq(pantryItems.userId, session.user.id),
orderBy: asc(pantryItems.rawName), orderBy: asc(pantryItems.rawName),
@@ -38,6 +39,7 @@ export default async function PantryPage() {
recipe: { columns: { id: true, title: true } }, recipe: { columns: { id: true, title: true } },
}, },
}), }),
loadIngredientAliasIndex(),
]); ]);
const mappedItems = items.map((i) => ({ const mappedItems = items.map((i) => ({
@@ -45,10 +47,12 @@ export default async function PantryPage() {
rawName: i.rawName, rawName: i.rawName,
quantity: i.quantity, quantity: i.quantity,
unit: i.unit, unit: i.unit,
notes: i.notes,
aisle: i.aisle,
expiresAt: i.expiresAt?.toISOString() ?? null, expiresAt: i.expiresAt?.toISOString() ?? null,
})); }));
const suggestions = scoreRecipesAgainstPantry(candidateRecipes, items) const suggestions = scoreRecipesAgainstPantry(candidateRecipes, items, aliasIndex)
.filter((s) => s.usesExpiring.length > 0) .filter((s) => s.usesExpiring.length > 0)
.slice(0, 3) .slice(0, 3)
.map((s) => { .map((s) => {
+13 -23
View File
@@ -14,6 +14,7 @@ import { PrintButton } from "@/components/recipe/print-button";
import { ShareRecipeButton } from "@/components/recipe/share-recipe-button"; import { ShareRecipeButton } from "@/components/recipe/share-recipe-button";
import { SaveOfflineButton } from "@/components/recipe/save-offline-button"; import { SaveOfflineButton } from "@/components/recipe/save-offline-button";
import { VersionHistoryButton } from "@/components/recipe/version-history-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 { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button";
import { ForkRecipeButton } from "@/components/recipe/fork-recipe-button"; import { ForkRecipeButton } from "@/components/recipe/fork-recipe-button";
import { NutritionPanel } from "@/components/recipe/nutrition-panel"; import { NutritionPanel } from "@/components/recipe/nutrition-panel";
@@ -109,7 +110,7 @@ export default async function RecipePage({ params }: Params) {
db.query.cookingHistory.findMany({ db.query.cookingHistory.findMany({
where: and(eq(cookingHistory.recipeId, id), eq(cookingHistory.userId, session.user.id)), where: and(eq(cookingHistory.recipeId, id), eq(cookingHistory.userId, session.user.id)),
orderBy: desc(cookingHistory.cookedAt), orderBy: desc(cookingHistory.cookedAt),
columns: { batchDishId: true, cookedAt: true }, columns: { id: true, batchDishId: true, cookedAt: true, servings: true, notes: true },
}), }),
getFeatureFlagMatrix(), getFeatureFlagMatrix(),
getFeaturePrefs(session.user.id), 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 // Non-batch cook log — batch-cook recipes track this per-dish instead
// (dishCookedAtMap above), logged via BatchCookDishes, not this list. // (dishCookedAtMap above), logged via BatchCookDishes, not this list.
const plainCookLog = dishCookLog.filter((l) => !l.batchDishId); const plainCookLog = dishCookLog
const cookCount = plainCookLog.length; .filter((l) => !l.batchDishId)
const lastCookedAt = plainCookLog[0]?.cookedAt.toISOString() ?? null; .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 avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null;
const ratingCount = ratingData[0]?.total ?? 0; const ratingCount = ratingData[0]?.total ?? 0;
@@ -209,24 +210,14 @@ export default async function RecipePage({ params }: Params) {
</Link> </Link>
)} )}
{visibleForks.length > 0 && ( {visibleForks.length > 0 && (
<div className="text-sm text-muted-foreground"> <ForkedByPopover
<p> label={
{visibleForks.length === 1 visibleForks.length === 1
? m.recipe.forkedByCountSingular ? m.recipe.forkedByCountSingular
: formatMessage(m.recipe.forkedByCountPlural, { count: visibleForks.length })} : formatMessage(m.recipe.forkedByCountPlural, { count: visibleForks.length })
</p> }
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1"> forks={visibleForks.map((f) => ({ id: f.child.id, title: f.child.title }))}
{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>
)} )}
<TooltipProvider> <TooltipProvider>
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"> <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 <MarkCookedSection
recipeId={id} recipeId={id}
baseServings={recipe.baseServings} baseServings={recipe.baseServings}
cookCount={cookCount} initialLogs={plainCookLog}
lastCookedAt={lastCookedAt}
/> />
</> </>
)} )}
+4 -2
View File
@@ -6,6 +6,7 @@ import { eq } from "@epicure/db";
import { getPublicUrl } from "@/lib/storage"; import { getPublicUrl } from "@/lib/storage";
import { CanCookContent } from "@/components/recipe/can-cook-content"; import { CanCookContent } from "@/components/recipe/can-cook-content";
import { scoreRecipesAgainstPantry } from "@/lib/pantry-match"; import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
import { loadIngredientAliasIndex } from "@/lib/ingredient-match";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
@@ -13,7 +14,7 @@ export default async function CanCookPage() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null; if (!session) return null;
const [userRecipes, pantry] = await Promise.all([ const [userRecipes, pantry, aliasIndex] = await Promise.all([
db.query.recipes.findMany({ db.query.recipes.findMany({
where: eq(recipes.authorId, session.user.id), where: eq(recipes.authorId, session.user.id),
with: { with: {
@@ -24,9 +25,10 @@ export default async function CanCookPage() {
db.query.pantryItems.findMany({ db.query.pantryItems.findMany({
where: eq(pantryItems.userId, session.user.id), 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]; const cover = s.recipe.photos?.find((p) => p.isCover) ?? s.recipe.photos?.[0];
return { return {
...s, ...s,
+10 -1
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { db, pantryItems, eq, and } from "@epicure/db"; import { db, pantryItems, eq, and } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth"; import { requireSessionOrApiKey } from "@/lib/api-auth";
import { findIngredientIdByName } from "@/lib/ingredient-match";
type Params = { params: Promise<{ id: string }> }; 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(), rawName: z.string().min(1).max(200).optional(),
quantity: z.string().nullable().optional(), quantity: z.string().nullable().optional(),
unit: 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(), expiresAt: z.string().datetime().nullable().optional(),
}).safeParse(body); }).safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const data = parsed.data; 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({ await db.update(pantryItems).set({
...(data.rawName && { rawName: data.rawName }), ...(data.rawName && { rawName: data.rawName, ingredientId }),
...(data.quantity !== undefined && { quantity: data.quantity ?? undefined }), ...(data.quantity !== undefined && { quantity: data.quantity ?? undefined }),
...(data.unit !== undefined && { unit: data.unit ?? 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 }), ...(data.expiresAt !== undefined && { expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined }),
}).where(eq(pantryItems.id, id)); }).where(eq(pantryItems.id, id));
+14 -7
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { db, pantryItems, eq } from "@epicure/db"; import { db, pantryItems, eq } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth"; import { requireSessionOrApiKey } from "@/lib/api-auth";
import { loadIngredientAliasIndex, resolveIngredientKey, findIngredientIdByName } from "@/lib/ingredient-match";
const Schema = z.object({ const Schema = z.object({
items: z.array(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 }); if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const userId = session!.user.id; const userId = session!.user.id;
const existing = await db.query.pantryItems.findMany({ const [existing, aliasIndex] = await Promise.all([
where: eq(pantryItems.userId, userId), db.query.pantryItems.findMany({ where: eq(pantryItems.userId, userId) }),
}); loadIngredientAliasIndex(),
]);
for (const incoming of parsed.data.items) { for (const incoming of parsed.data.items) {
const key = incoming.rawName.toLowerCase(); const key = resolveIngredientKey(incoming.rawName, aliasIndex);
const match = existing.find( 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) { if (match) {
@@ -41,13 +43,18 @@ export async function POST(req: NextRequest) {
} }
// if quantities aren't numeric, leave as-is (item already exists) // if quantities aren't numeric, leave as-is (item already exists)
} else { } else {
await db.insert(pantryItems).values({ const ingredientId = await findIngredientIdByName(incoming.rawName);
const created = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
userId, userId,
ingredientId,
rawName: incoming.rawName, rawName: incoming.rawName,
quantity: incoming.quantity, quantity: incoming.quantity,
unit: incoming.unit, 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 });
}
+7
View File
@@ -2,11 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { db, pantryItems, eq, desc, sql } from "@epicure/db"; import { db, pantryItems, eq, desc, sql } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth"; import { requireSessionOrApiKey } from "@/lib/api-auth";
import { findIngredientIdByName } from "@/lib/ingredient-match";
const Schema = z.object({ const Schema = z.object({
rawName: z.string().min(1).max(200), rawName: z.string().min(1).max(200),
quantity: z.string().optional(), quantity: z.string().optional(),
unit: z.string().optional(), unit: z.string().optional(),
notes: z.string().max(500).optional(),
aisle: z.string().max(50).optional(),
expiresAt: z.string().datetime().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 }); if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const ingredientId = await findIngredientIdByName(parsed.data.rawName);
await db.insert(pantryItems).values({ await db.insert(pantryItems).values({
id, id,
userId: session!.user.id, userId: session!.user.id,
ingredientId,
rawName: parsed.data.rawName, rawName: parsed.data.rawName,
quantity: parsed.data.quantity, quantity: parsed.data.quantity,
unit: parsed.data.unit, unit: parsed.data.unit,
notes: parsed.data.notes,
aisle: parsed.data.aisle,
expiresAt: parsed.data.expiresAt ? new Date(parsed.data.expiresAt) : undefined, 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 { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; 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 { requireSessionOrApiKey } from "@/lib/api-auth";
import { loadIngredientAliasIndex, resolveIngredientKey } from "@/lib/ingredient-match";
type Params = { params: Promise<{ id: string }> }; 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({ const Schema = z.object({
servings: z.number().int().min(1).max(1000).optional(), servings: z.number().int().min(1).max(1000).optional(),
notes: z.string().max(2000).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 cookedAt = data.cookedAt ? new Date(data.cookedAt) : new Date();
const logId = crypto.randomUUID();
await db.insert(cookingHistory).values({ await db.insert(cookingHistory).values({
id: crypto.randomUUID(), id: logId,
userId, userId,
recipeId: id, recipeId: id,
batchDishId: data.batchDishId, batchDishId: data.batchDishId,
@@ -70,11 +89,12 @@ export async function POST(req: NextRequest, { params }: Params) {
const userPantry = await db.query.pantryItems.findMany({ const userPantry = await db.query.pantryItems.findMany({
where: eq(pantryItems.userId, userId), where: eq(pantryItems.userId, userId),
}); });
const aliasIndex = await loadIngredientAliasIndex();
for (const ing of ings) { for (const ing of ings) {
const key = ing.rawName.toLowerCase(); const key = resolveIngredientKey(ing.rawName, aliasIndex);
const pantryItem = userPantry.find( 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; 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 });
} }
+6 -2
View File
@@ -4,6 +4,7 @@ import { db, shoppingLists, shoppingListItems, mealPlans, mealPlanEntries, recip
import { requireSessionOrApiKey } from "@/lib/api-auth"; import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyPantryToItems, mergeIngredients } from "@/lib/pantry-shopping-match"; import { applyPantryToItems, mergeIngredients } from "@/lib/pantry-shopping-match";
import { guessAisle } from "@/lib/grocery-categories"; import { guessAisle } from "@/lib/grocery-categories";
import { loadIngredientAliasIndex } from "@/lib/ingredient-match";
const CreateSchema = z.object({ const CreateSchema = z.object({
name: z.string().min(1).max(100), 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 // 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 // drops an item — fully-covered items are still inserted, flagged `inPantry`, so nothing
// disappears from view without the user seeing it. // disappears from view without the user seeing it.
const pantry = await db.query.pantryItems.findMany({ where: eq(pantryItems.userId, session!.user.id) }); const [pantry, aliasIndex] = await Promise.all([
items = applyPantryToItems(mergedItems, pantry); db.query.pantryItems.findMany({ where: eq(pantryItems.userId, session!.user.id) }),
loadIngredientAliasIndex(),
]);
items = applyPantryToItems(mergedItems, pantry, aliasIndex);
} }
} }
} }
+2 -1
View File
@@ -3,6 +3,7 @@ import { auth } from "@/lib/auth/server";
import { db, pantryItems, eq, asc } from "@epicure/db"; import { db, pantryItems, eq, asc } from "@epicure/db";
import { PrintTrigger } from "@/components/recipe/print-trigger"; import { PrintTrigger } from "@/components/recipe/print-trigger";
import { getMessages, formatMessage } from "@/lib/i18n/server"; import { getMessages, formatMessage } from "@/lib/i18n/server";
import { formatQuantity, hasQuantity } from "@/lib/fractions";
export default async function PantryPrintPage() { export default async function PantryPrintPage() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
@@ -73,7 +74,7 @@ export default async function PantryPrintPage() {
return ( return (
<tr key={item.id}> <tr key={item.id}>
<td>{item.rawName}</td> <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}> <td className={expiryClass}>
{exp {exp
? daysLeft !== null && daysLeft < 0 ? daysLeft !== null && daysLeft < 0
@@ -5,6 +5,7 @@ import { auth } from "@/lib/auth/server";
import { db, shoppingLists, eq, and } from "@epicure/db"; import { db, shoppingLists, eq, and } from "@epicure/db";
import { PrintTrigger } from "@/components/recipe/print-trigger"; import { PrintTrigger } from "@/components/recipe/print-trigger";
import { getMessages, formatMessage } from "@/lib/i18n/server"; import { getMessages, formatMessage } from "@/lib/i18n/server";
import { formatQuantity, hasQuantity } from "@/lib/fractions";
type Params = { params: Promise<{ id: string }> }; 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" : ""}> <li key={item.id} className={item.checked ? "checked" : ""}>
<span className="check" /> <span className="check" />
<span className="qty"> <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>
<span>{item.rawName}</span> <span>{item.rawName}</span>
</li> </li>
+103 -15
View File
@@ -3,11 +3,14 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; 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 { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { 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 { EmptyState } from "@/components/shared/empty-state";
import { import {
AlertDialog, AlertDialog,
@@ -20,13 +23,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
type PantryItem = { const OTHER_KEY = "__other__";
id: string;
rawName: string;
quantity: string | null;
unit: string | null;
expiresAt: string | null;
};
function daysUntilExpiry(dateStr: string): number { function daysUntilExpiry(dateStr: string): number {
const diff = new Date(dateStr).getTime() - Date.now(); const diff = new Date(dateStr).getTime() - Date.now();
@@ -35,6 +32,7 @@ function daysUntilExpiry(dateStr: string): number {
export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) { export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) {
const t = useTranslations("pantry"); const t = useTranslations("pantry");
const tShopping = useTranslations("shoppingLists");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const router = useRouter(); const router = useRouter();
const [items, setItems] = useState<PantryItem[]>(initialItems); const [items, setItems] = useState<PantryItem[]>(initialItems);
@@ -44,6 +42,23 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
const [expiresAt, setExpiresAt] = useState(""); const [expiresAt, setExpiresAt] = useState("");
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [confirmId, setConfirmId] = useState<string | null>(null); const [confirmId, setConfirmId] = useState<string | null>(null);
const [editingItem, setEditingItem] = useState<PantryItem | null>(null);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [merging, setMerging] = useState(false);
async function mergeDuplicates() {
setMerging(true);
try {
const res = await fetch("/api/v1/pantry/merge-duplicates", { method: "POST" });
if (!res.ok) { toast.error(t("mergeDuplicatesFailed")); return; }
const { removed } = await res.json() as { mergedGroups: number; removed: number };
if (removed === 0) toast.success(t("mergeDuplicatesNoneFound"));
else toast.success(t("mergeDuplicatesSuccess", { count: removed }));
router.refresh();
} finally {
setMerging(false);
}
}
async function add() { async function add() {
if (!name.trim()) return; if (!name.trim()) return;
@@ -61,7 +76,10 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
}); });
if (!res.ok) { toast.error(t("addFailed")); return; } if (!res.ok) { toast.error(t("addFailed")); return; }
const { id } = await res.json() as { id: string }; 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(""); setName(""); setQuantity(""); setUnit(""); setExpiresAt("");
} finally { } finally {
setAdding(false); setAdding(false);
@@ -74,14 +92,38 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
else toast.error(t("removeFailed")); 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 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 && b.expiresAt) return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime();
if (a.expiresAt) return -1; if (a.expiresAt) return -1;
if (b.expiresAt) return 1; if (b.expiresAt) return 1;
return a.rawName.localeCompare(b.rawName); return a.rawName.localeCompare(b.rawName);
}); };
const grouped = new Map<string, PantryItem[]>();
for (const item of items) {
const key = item.aisle ?? OTHER_KEY;
const group = grouped.get(key) ?? [];
group.push(item);
grouped.set(key, group);
}
for (const group of grouped.values()) group.sort(sortWithinGroup);
function categoryLabel(key: string): string {
return key === OTHER_KEY ? tShopping("aisleOther") : tShopping(`categories.${key}`);
}
const sortedGroupKeys = [...grouped.keys()].sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b)));
const showGroupHeaders = grouped.size > 1;
return ( return (
<div className="space-y-6 max-w-2xl"> <div className="space-y-6 max-w-2xl">
@@ -95,30 +137,59 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
<Plus className="h-4 w-4" /> {t("add")} <Plus className="h-4 w-4" /> {t("add")}
</Button> </Button>
<PantryScanDialog onAdded={() => router.refresh()} /> <PantryScanDialog onAdded={() => router.refresh()} />
<Button variant="outline" size="sm" onClick={() => { void mergeDuplicates(); }} disabled={merging}>
<Merge className="h-4 w-4" /> {t("mergeDuplicates")}
</Button>
</div> </div>
{/* Item list */} {/* Item list */}
{items.length === 0 ? ( {items.length === 0 ? (
<EmptyState icon={Package} title={t("empty")} description={t("emptyDescription")} compact /> <EmptyState icon={Package} title={t("empty")} description={t("emptyDescription")} compact />
) : ( ) : (
<div className="rounded-xl border divide-y"> <div className="space-y-4">
{sorted.map((item) => { {sortedGroupKeys.map((key) => {
const groupItems = grouped.get(key)!;
const isCollapsed = collapsed.has(key);
return (
<div key={key} className="rounded-xl border overflow-hidden">
{showGroupHeaders && (
<button
type="button"
onClick={() => toggleCollapsed(key)}
className="w-full flex items-center justify-between gap-2 px-4 py-2 bg-muted/40 hover:bg-muted/60 transition-colors text-left"
>
<span className="text-sm font-medium">{categoryLabel(key)}</span>
<span className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{groupItems.length}</span>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform", isCollapsed && "-rotate-90")} />
</span>
</button>
)}
{!isCollapsed && (
<div className="divide-y">
{groupItems.map((item) => {
const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null; const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null;
const expiring = days !== null && days <= 3; const expiring = days !== null && days <= 3;
const expired = days !== null && days < 0; const expired = days !== null && days < 0;
return ( return (
<div key={item.id} className="flex items-center gap-3 px-4 py-3 hover:bg-muted/30"> <div key={item.id} className="flex items-center gap-3 px-4 py-3 hover:bg-muted/30">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{item.rawName}</span> <span className="font-medium text-sm">{item.rawName}</span>
{item.quantity && <span className="text-xs text-muted-foreground">{item.quantity}{item.unit ? ` ${item.unit}` : ""}</span>} {hasQuantity(item.quantity) && (
<span className="text-xs text-muted-foreground">{formatQuantity(parseFloat(item.quantity!))}{item.unit ? ` ${item.unit}` : ""}</span>
)}
{expired && <span className="text-xs text-destructive flex items-center gap-1"><AlertTriangle className="h-3 w-3" />{t("expired")}</span>} {expired && <span className="text-xs text-destructive flex items-center gap-1"><AlertTriangle className="h-3 w-3" />{t("expired")}</span>}
{expiring && !expired && <span className="text-xs text-orange-500 flex items-center gap-1"><AlertTriangle className="h-3 w-3" />{t("expiresInDays", { days })}</span>} {expiring && !expired && <span className="text-xs text-orange-500 flex items-center gap-1"><AlertTriangle className="h-3 w-3" />{t("expiresInDays", { days })}</span>}
</div> </div>
{item.expiresAt && !expired && !expiring && ( {item.expiresAt && !expired && !expiring && (
<p className="text-xs text-muted-foreground">{t("expiresOn", { date: new Date(item.expiresAt).toLocaleDateString() })}</p> <p className="text-xs text-muted-foreground">{t("expiresOn", { date: new Date(item.expiresAt).toLocaleDateString() })}</p>
)} )}
{item.notes && <p className="text-xs text-muted-foreground italic mt-0.5">{item.notes}</p>}
</div> </div>
<button onClick={() => setEditingItem(item)} aria-label={t("editItem")} className="text-muted-foreground hover:text-foreground transition-colors">
<Pencil className="h-4 w-4" />
</button>
<button onClick={() => setConfirmId(item.id)} className="text-muted-foreground hover:text-destructive transition-colors"> <button onClick={() => setConfirmId(item.id)} className="text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
@@ -127,6 +198,11 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
})} })}
</div> </div>
)} )}
</div>
);
})}
</div>
)}
<AlertDialog open={confirmId !== null} onOpenChange={(open) => !open && setConfirmId(null)}> <AlertDialog open={confirmId !== null} onOpenChange={(open) => !open && setConfirmId(null)}>
<AlertDialogContent> <AlertDialogContent>
@@ -150,6 +226,18 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{editingItem && (
<PantryItemDialog
item={editingItem}
open={!!editingItem}
onOpenChange={(open) => !open && setEditingItem(null)}
onSaved={(updated) => {
setItems((prev) => prev.map((i) => (i.id === updated.id ? updated : i)));
setEditingItem(null);
}}
/>
)}
</div> </div>
); );
} }
@@ -34,7 +34,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } 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 { guessAisle, GROCERY_CATEGORIES } from "@/lib/grocery-categories";
import { import {
DndContext, DndContext,
@@ -694,7 +694,7 @@ function ItemRow({ item, readOnly, categoryOptions, categoryLabel, tShopping, on
)} )}
{(hasQuantity(item.quantity) || item.unit) && ( {(hasQuantity(item.quantity) || item.unit) && (
<span className={cn("text-xs text-muted-foreground tabular-nums shrink-0", item.checked && "opacity-50")}> <span className={cn("text-xs text-muted-foreground tabular-nums shrink-0", item.checked && "opacity-50")}>
{hasQuantity(item.quantity) ? item.quantity : ""}{item.unit ? ` ${item.unit}` : ""} {hasQuantity(item.quantity) ? formatQuantity(parseFloat(item.quantity!)) : ""}{item.unit ? ` ${item.unit}` : ""}
</span> </span>
)} )}
</button> </button>
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("editDialogTitle")}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="pantry-edit-name">{t("itemNamePlaceholder")}</Label>
<Input id="pantry-edit-name" value={rawName} onChange={(e) => setRawName(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="pantry-edit-qty">{t("qtyPlaceholder")}</Label>
<Input id="pantry-edit-qty" value={quantity} onChange={(e) => setQuantity(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="pantry-edit-unit">{t("unitPlaceholder")}</Label>
<Input id="pantry-edit-unit" value={unit} onChange={(e) => setUnit(e.target.value)} />
</div>
</div>
<div className="space-y-1.5">
<Label>{t("categoryLabel")}</Label>
<Select value={aisle} onValueChange={(v) => setAisle(v ?? OTHER_VALUE)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={OTHER_VALUE}>{tShopping("aisleOther")}</SelectItem>
{GROCERY_CATEGORIES.map((c) => (
<SelectItem key={c} value={c}>{tShopping(`categories.${c}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="pantry-edit-expiry">{t("colExpires")}</Label>
<Input id="pantry-edit-expiry" type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="pantry-edit-notes">{t("notesLabel")}</Label>
<Textarea id="pantry-edit-notes" value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} placeholder={t("notesPlaceholder")} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{tCommon("cancel")}
</Button>
<Button type="button" onClick={() => { void handleSave(); }} disabled={saving || !rawName.trim()}>
{saving ? tCommon("saving") : tCommon("save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,103 @@
"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";
export type CookLog = {
id: string;
cookedAt: string;
servings: number | null;
notes: string | null;
};
export function EditCookLogDialog({
recipeId,
log,
open,
onOpenChange,
onSaved,
}: {
recipeId: string;
log: CookLog;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: (updated: CookLog) => void;
}) {
const t = useTranslations("recipe");
const tCommon = useTranslations("common");
const [date, setDate] = useState(log.cookedAt.slice(0, 10));
const [servings, setServings] = useState(log.servings ?? "");
const [notes, setNotes] = useState(log.notes ?? "");
const [saving, setSaving] = useState(false);
async function handleSave() {
setSaving(true);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/cooked/${log.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
cookedAt: date,
servings: servings === "" ? null : Number(servings),
notes: notes.trim() || null,
}),
});
if (!res.ok) { toast.error(t("editCookLogFailed")); return; }
toast.success(t("editCookLogSaved"));
onSaved({ id: log.id, cookedAt: new Date(date).toISOString(), servings: servings === "" ? null : Number(servings), notes: notes.trim() || null });
onOpenChange(false);
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("editCookLogTitle")}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="cook-log-date">{t("markCookedDateLabel")}</Label>
<Input id="cook-log-date" type="date" value={date} max={new Date().toISOString().slice(0, 10)} onChange={(e) => setDate(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="cook-log-servings">{t("markCookedServingsLabel")}</Label>
<Input
id="cook-log-servings"
type="number"
min={1}
value={servings}
onChange={(e) => setServings(e.target.value === "" ? "" : Math.max(1, Number(e.target.value)))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cook-log-notes">{t("cookLogNotesLabel")}</Label>
<Textarea id="cook-log-notes" value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{tCommon("cancel")}
</Button>
<Button type="button" onClick={() => { void handleSave(); }} disabled={saving}>
{saving ? tCommon("saving") : tCommon("save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export function ForkedByPopover({
label,
forks,
}: {
label: string;
forks: { id: string; title: string }[];
}) {
return (
<Popover>
<PopoverTrigger className="text-sm text-muted-foreground hover:text-foreground underline-offset-2 hover:underline text-left">
{label}
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start">
<ul className="space-y-0.5">
{forks.map((f) => (
<li key={f.id}>
<Link
href={`/recipes/${f.id}`}
className="block rounded px-2 py-1.5 text-sm hover:bg-accent truncate"
>
{f.title}
</Link>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
@@ -25,7 +25,7 @@ interface MarkCookedDialogProps {
baseServings: number; baseServings: number;
batchDishId?: string; batchDishId?: string;
trigger: React.ReactNode; trigger: React.ReactNode;
onLogged?: (cookedAt: string) => void; onLogged?: (log: { id: string; cookedAt: string; servings: number }) => void;
} }
/** Logs a cook event — date, servings, and whether to deduct matching /** Logs a cook event — date, servings, and whether to deduct matching
@@ -55,9 +55,10 @@ export function MarkCookedDialog({ recipeId, baseServings, batchDishId, trigger,
}), }),
}); });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
const { id } = await res.json() as { id: string };
toast.success(t("markCookedSuccess")); toast.success(t("markCookedSuccess"));
setOpen(false); setOpen(false);
onLogged?.(date); onLogged?.({ id, cookedAt: date, servings });
} catch { } catch {
toast.error(t("markCookedFailed")); toast.error(t("markCookedFailed"));
} finally { } finally {
@@ -1,33 +1,72 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ChefHat } from "lucide-react"; import { toast } from "sonner";
import { ChefHat, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useLocale } from "@/lib/i18n/provider"; import { useLocale } from "@/lib/i18n/provider";
import { MarkCookedDialog } from "./mark-cooked-dialog"; import { MarkCookedDialog } from "./mark-cooked-dialog";
import { EditCookLogDialog, type CookLog } from "./edit-cook-log-dialog";
const TOOLTIP_DATE_LIMIT = 8;
export function MarkCookedSection({ export function MarkCookedSection({
recipeId, recipeId,
baseServings, baseServings,
cookCount, initialLogs,
lastCookedAt,
}: { }: {
recipeId: string; recipeId: string;
baseServings: number; baseServings: number;
cookCount: number; initialLogs: CookLog[];
lastCookedAt: string | null;
}) { }) {
const t = useTranslations("recipe"); const t = useTranslations("recipe");
const router = useRouter(); const tCommon = useTranslations("common");
const { locale } = useLocale(); const { locale } = useLocale();
const [logs, setLogs] = useState<CookLog[]>(initialLogs);
const [sheetOpen, setSheetOpen] = useState(false);
const [editingLog, setEditingLog] = useState<CookLog | null>(null);
const [confirmId, setConfirmId] = useState<string | null>(null);
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString(locale, { month: "short", day: "numeric", year: "numeric" });
}
async function handleDelete(id: string) {
const res = await fetch(`/api/v1/recipes/${recipeId}/cooked/${id}`, { method: "DELETE" });
if (res.ok) {
setLogs((prev) => prev.filter((l) => l.id !== id));
toast.success(t("deleteCookLogSuccess"));
} else {
toast.error(t("deleteCookLogFailed"));
}
}
const cookCount = logs.length;
const lastCookedAt = logs[0]?.cookedAt ?? null;
const logPendingDelete = logs.find((l) => l.id === confirmId) ?? null;
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<MarkCookedDialog <MarkCookedDialog
recipeId={recipeId} recipeId={recipeId}
baseServings={baseServings} baseServings={baseServings}
onLogged={() => router.refresh()} onLogged={(log) => {
const entry = { id: log.id, cookedAt: new Date(log.cookedAt).toISOString(), servings: log.servings, notes: null };
setLogs((prev) => [...prev, entry].sort((a, b) => new Date(b.cookedAt).getTime() - new Date(a.cookedAt).getTime()));
}}
trigger={ trigger={
<Button type="button" variant="outline" size="sm"> <Button type="button" variant="outline" size="sm">
<ChefHat className="h-3.5 w-3.5" /> <ChefHat className="h-3.5 w-3.5" />
@@ -36,11 +75,95 @@ export function MarkCookedSection({
} }
/> />
{cookCount > 0 && ( {cookCount > 0 && (
<p className="text-xs text-muted-foreground"> <TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<button
type="button"
onClick={() => setSheetOpen(true)}
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline text-left"
>
{cookCount === 1 ? t("markCookedCountSingular") : t("markCookedCountPlural", { count: cookCount })} {cookCount === 1 ? t("markCookedCountSingular") : t("markCookedCountPlural", { count: cookCount })}
{lastCookedAt && t("markCookedLast", { date: new Date(lastCookedAt).toLocaleDateString(locale, { month: "short", day: "numeric" }) })} {lastCookedAt && t("markCookedLast", { date: formatDate(lastCookedAt) })}
</p> </button>
} />
<TooltipContent>
<ul className="space-y-0.5">
{logs.slice(0, TOOLTIP_DATE_LIMIT).map((l) => (
<li key={l.id}>{formatDate(l.cookedAt)}</li>
))}
</ul>
{logs.length > TOOLTIP_DATE_LIMIT && (
<p className="text-muted-foreground mt-1">{t("cookLogMore", { count: logs.length - TOOLTIP_DATE_LIMIT })}</p>
)} )}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>{t("cookLogSheetTitle")}</SheetTitle>
</SheetHeader>
<div className="p-2 mt-6 space-y-2">
{logs.length === 0 && <p className="text-sm text-muted-foreground">{t("cookLogEmpty")}</p>}
{logs.map((log) => (
<div key={log.id} className="flex items-center justify-between gap-2 border rounded-lg p-3">
<div className="min-w-0">
<p className="text-sm font-medium">{formatDate(log.cookedAt)}</p>
{log.servings && <p className="text-xs text-muted-foreground">{t("markCookedServingsLabel")}: {log.servings}</p>}
{log.notes && <p className="text-xs text-muted-foreground italic">{log.notes}</p>}
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => setEditingLog(log)} aria-label={tCommon("edit")} className="text-muted-foreground hover:text-foreground p-1.5">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => setConfirmId(log.id)} aria-label={tCommon("delete")} className="text-muted-foreground hover:text-destructive p-1.5">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
</SheetContent>
</Sheet>
{editingLog && (
<EditCookLogDialog
recipeId={recipeId}
log={editingLog}
open={!!editingLog}
onOpenChange={(open) => !open && setEditingLog(null)}
onSaved={(updated) => {
setLogs((prev) => [...prev.filter((l) => l.id !== updated.id), updated].sort((a, b) => new Date(b.cookedAt).getTime() - new Date(a.cookedAt).getTime()));
setEditingLog(null);
}}
/>
)}
<AlertDialog open={confirmId !== null} onOpenChange={(open) => !open && setConfirmId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteCookLogConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{logPendingDelete ? t("deleteCookLogConfirmDescription", { date: formatDate(logPendingDelete.cookedAt) }) : ""}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (confirmId) void handleDelete(confirmId);
setConfirmId(null);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
+15 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together. // 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 = { export type ChangelogEntry = {
version: string; version: string;
@@ -11,6 +11,20 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: 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", version: "0.82.0",
date: "2026-07-24 17:45", 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 = { type PantryMarkdownInput = {
items: Array<{ rawName: string; quantity: string | null; unit: string | null; expiresAt: string | null }>; 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", ""]; const lines: string[] = ["# Pantry", ""];
for (const item of pantry.items) { 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()})` : ""; const expiry = item.expiresAt ? ` (expires ${new Date(item.expiresAt).toLocaleDateString()})` : "";
lines.push(`- ${qty ? `${qty} ` : ""}${item.rawName}${expiry}`); lines.push(`- ${qty ? `${qty} ` : ""}${item.rawName}${expiry}`);
} }
+3 -1
View File
@@ -1,3 +1,5 @@
import { formatQuantity, hasQuantity } from "@/lib/fractions";
type ShoppingListMarkdownInput = { type ShoppingListMarkdownInput = {
name: string; name: string;
items: Array<{ rawName: string; quantity: string | null; unit: string | null; aisle: string | null; checked: boolean }>; 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) { for (const [aisle, items] of byAisle) {
lines.push(`## ${aisle}`, ""); lines.push(`## ${aisle}`, "");
for (const item of items) { 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(`- [${item.checked ? "x" : " "}] ${qty ? `${qty} ` : ""}${item.rawName}`);
} }
lines.push(""); lines.push("");
+9 -4
View File
@@ -213,7 +213,8 @@ export function generateOpenApiSpec(): object {
const PantryItemRef = registry.register("PantryItem", z.object({ const PantryItemRef = registry.register("PantryItem", z.object({
id: z.string(), rawName: z.string(), quantity: z.string().nullable(), 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({ 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: "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/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}/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: "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: "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 } } } } }); 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: "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: "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: "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: "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(), 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: "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: "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/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/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 } } } } }); 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 const EXPIRING_WITHIN_DAYS = 3;
export function isExpiringSoon(expiresAt: Date | null): boolean { export function isExpiringSoon(expiresAt: Date | null): boolean {
@@ -10,23 +12,28 @@ type ScorableRecipe<T> = T & { ingredients: { rawName: string }[] };
export function scoreRecipesAgainstPantry<T>( export function scoreRecipesAgainstPantry<T>(
recipesList: ScorableRecipe<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( 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 return recipesList
.filter((r) => r.ingredients.length > 0) .filter((r) => r.ingredients.length > 0)
.map((recipe) => { .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 const missing = recipe.ingredients
.filter((ing) => !pantryKeys.has(ing.rawName.toLowerCase())) .filter((ing) => !pantryKeys.has(keyOf(ing.rawName)))
.map((ing) => ing.rawName) .map((ing) => ing.rawName)
.slice(0, 5); .slice(0, 5);
const usesExpiring = recipe.ingredients const usesExpiring = recipe.ingredients
.filter((ing) => expiringSoonKeys.has(ing.rawName.toLowerCase())) .filter((ing) => expiringSoonKeys.has(keyOf(ing.rawName)))
.map((ing) => ing.rawName); .map((ing) => ing.rawName);
const total = recipe.ingredients.length; const total = recipe.ingredients.length;
return { recipe, matched, total, pct: Math.round((matched / total) * 100), missing, usesExpiring }; return { recipe, matched, total, pct: Math.round((matched / total) * 100), missing, usesExpiring };
+14 -5
View File
@@ -13,6 +13,7 @@
*/ */
import { extractIngredientQuantity } from "./extract-ingredient-quantity"; import { extractIngredientQuantity } from "./extract-ingredient-quantity";
import { resolveIngredientKey, type IngredientAliasIndex } from "./ingredient-match";
export type PantrySourceItem = { export type PantrySourceItem = {
ingredientId: string | null; ingredientId: string | null;
@@ -55,7 +56,8 @@ export function formatQuantity(n: number): string {
*/ */
export function applyPantryToItems( export function applyPantryToItems(
items: ShoppingSourceItem[], items: ShoppingSourceItem[],
pantry: PantrySourceItem[] pantry: PantrySourceItem[],
aliasIndex?: IngredientAliasIndex
): PantryAdjustedItem[] { ): PantryAdjustedItem[] {
const pantryByIngredientId = new Map<string, PantrySourceItem[]>(); const pantryByIngredientId = new Map<string, PantrySourceItem[]>();
const pantryByName = new Map<string, PantrySourceItem[]>(); const pantryByName = new Map<string, PantrySourceItem[]>();
@@ -66,10 +68,15 @@ export function applyPantryToItems(
list.push(p); list.push(p);
pantryByIngredientId.set(p.ingredientId, list); pantryByIngredientId.set(p.ingredientId, list);
} }
const nameKey = normalizeName(p.rawName); // Index under both the plain normalized name and its alias-resolved
const list = pantryByName.get(nameKey) ?? []; // 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); list.push(p);
pantryByName.set(nameKey, list); pantryByName.set(key, list);
}
} }
return items.map((item) => { return items.map((item) => {
@@ -78,7 +85,9 @@ export function applyPantryToItems(
if (item.ingredientId && pantryByIngredientId.has(item.ingredientId)) { if (item.ingredientId && pantryByIngredientId.has(item.ingredientId)) {
matches = pantryByIngredientId.get(item.ingredientId); matches = pantryByIngredientId.get(item.ingredientId);
} else { } 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) { if (!matches || matches.length === 0) {
+25 -1
View File
@@ -161,6 +161,17 @@
"markCookedCountSingular": "Cooked 1 time", "markCookedCountSingular": "Cooked 1 time",
"markCookedCountPlural": "Cooked {count} times", "markCookedCountPlural": "Cooked {count} times",
"markCookedLast": " · last {date}", "markCookedLast": " · last {date}",
"cookLogMore": "+{count} more",
"cookLogSheetTitle": "Cook history",
"cookLogEmpty": "No cooks logged yet.",
"cookLogNotesLabel": "Notes",
"editCookLogTitle": "Edit cook log",
"editCookLogSaved": "Cook log updated",
"editCookLogFailed": "Failed to update cook log",
"deleteCookLogSuccess": "Cook log removed",
"deleteCookLogFailed": "Failed to remove cook log",
"deleteCookLogConfirmTitle": "Remove this cook log?",
"deleteCookLogConfirmDescription": "Remove the {date} entry from your cook history? This won't undo any pantry deduction from when it was logged.",
"batchCookExpiresOn": "Cooked — good until {date}", "batchCookExpiresOn": "Cooked — good until {date}",
"servings": "{count} servings", "servings": "{count} servings",
"prep": "{mins}m prep", "prep": "{mins}m prep",
@@ -745,6 +756,7 @@
"loading": "Loading…", "loading": "Loading…",
"error": "Something went wrong", "error": "Something went wrong",
"save": "Save", "save": "Save",
"saving": "Saving…",
"saved": "Saved", "saved": "Saved",
"saveFailed": "Failed to save", "saveFailed": "Failed to save",
"exportMarkdown": "Export as Markdown", "exportMarkdown": "Export as Markdown",
@@ -1158,7 +1170,19 @@
"scanNotFound": "Product not found. Try a photo scan instead.", "scanNotFound": "Product not found. Try a photo scan instead.",
"scanPhotoFailed": "Photo scan failed", "scanPhotoFailed": "Photo scan failed",
"scanNoItemsFound": "No items recognized in this photo", "scanNoItemsFound": "No items recognized in this photo",
"cameraError": "Couldn't access the camera" "cameraError": "Couldn't access the camera",
"editItem": "Edit item",
"editDialogTitle": "Edit pantry item",
"editSaved": "Item updated",
"editFailed": "Failed to update item",
"categoryLabel": "Category",
"notesLabel": "Notes",
"notesPlaceholder": "e.g. opened, half used, brand preference…",
"uncategorized": "Uncategorized",
"mergeDuplicates": "Merge duplicates",
"mergeDuplicatesFailed": "Failed to merge duplicates",
"mergeDuplicatesNoneFound": "No duplicates found",
"mergeDuplicatesSuccess": "{count, plural, one {Merged 1 duplicate item} other {Merged {count} duplicate items}}"
}, },
"feed": { "feed": {
"title": "Feed", "title": "Feed",
+25 -1
View File
@@ -161,6 +161,17 @@
"markCookedCountSingular": "Cuisiné 1 fois", "markCookedCountSingular": "Cuisiné 1 fois",
"markCookedCountPlural": "Cuisiné {count} fois", "markCookedCountPlural": "Cuisiné {count} fois",
"markCookedLast": " · dernière fois le {date}", "markCookedLast": " · dernière fois le {date}",
"cookLogMore": "+{count} de plus",
"cookLogSheetTitle": "Historique de cuisine",
"cookLogEmpty": "Aucune cuisson enregistrée pour le moment.",
"cookLogNotesLabel": "Notes",
"editCookLogTitle": "Modifier l'entrée",
"editCookLogSaved": "Entrée mise à jour",
"editCookLogFailed": "Échec de la mise à jour",
"deleteCookLogSuccess": "Entrée supprimée",
"deleteCookLogFailed": "Échec de la suppression",
"deleteCookLogConfirmTitle": "Supprimer cette entrée ?",
"deleteCookLogConfirmDescription": "Supprimer l'entrée du {date} de votre historique ? Cela n'annule pas la déduction du garde-manger effectuée lors de l'enregistrement.",
"batchCookExpiresOn": "Cuisiné — bon jusqu'au {date}", "batchCookExpiresOn": "Cuisiné — bon jusqu'au {date}",
"servings": "{count} portions", "servings": "{count} portions",
"prep": "{mins}m prép.", "prep": "{mins}m prép.",
@@ -745,6 +756,7 @@
"loading": "Chargement…", "loading": "Chargement…",
"error": "Une erreur s'est produite", "error": "Une erreur s'est produite",
"save": "Enregistrer", "save": "Enregistrer",
"saving": "Enregistrement…",
"saved": "Enregistré", "saved": "Enregistré",
"saveFailed": "Échec de l'enregistrement", "saveFailed": "Échec de l'enregistrement",
"exportMarkdown": "Exporter en Markdown", "exportMarkdown": "Exporter en Markdown",
@@ -1149,7 +1161,19 @@
"scanNotFound": "Produit introuvable. Essayez un scan photo.", "scanNotFound": "Produit introuvable. Essayez un scan photo.",
"scanPhotoFailed": "Échec du scan photo", "scanPhotoFailed": "Échec du scan photo",
"scanNoItemsFound": "Aucun article reconnu sur cette photo", "scanNoItemsFound": "Aucun article reconnu sur cette photo",
"cameraError": "Impossible d'accéder à la caméra" "cameraError": "Impossible d'accéder à la caméra",
"editItem": "Modifier l'article",
"editDialogTitle": "Modifier l'article du garde-manger",
"editSaved": "Article mis à jour",
"editFailed": "Échec de la mise à jour",
"categoryLabel": "Catégorie",
"notesLabel": "Notes",
"notesPlaceholder": "ex. ouvert, à moitié utilisé, marque préférée…",
"uncategorized": "Sans catégorie",
"mergeDuplicates": "Fusionner les doublons",
"mergeDuplicatesFailed": "Échec de la fusion des doublons",
"mergeDuplicatesNoneFound": "Aucun doublon trouvé",
"mergeDuplicatesSuccess": "{count, plural, one {1 doublon fusionné} other {{count} doublons fusionnés}}"
}, },
"feed": { "feed": {
"title": "Fil d'actualité", "title": "Fil d'actualité",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@epicure/web", "name": "@epicure/web",
"version": "0.82.0", "version": "0.83.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "epicure", "name": "epicure",
"version": "0.82.0", "version": "0.83.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm --filter web dev", "dev": "pnpm --filter web dev",
@@ -0,0 +1,2 @@
ALTER TABLE "pantry_items" ADD COLUMN "notes" text;--> statement-breakpoint
ALTER TABLE "pantry_items" ADD COLUMN "aisle" text;
File diff suppressed because it is too large Load Diff
@@ -463,6 +463,13 @@
"when": 1784894764837, "when": 1784894764837,
"tag": "0065_cooing_carnage", "tag": "0065_cooing_carnage",
"breakpoints": true "breakpoints": true
},
{
"idx": 66,
"version": "7",
"when": 1784897120622,
"tag": "0066_short_klaw",
"breakpoints": true
} }
] ]
} }
+6
View File
@@ -54,6 +54,12 @@ export const pantryItems = pgTable("pantry_items", {
rawName: text("raw_name").notNull(), rawName: text("raw_name").notNull(),
quantity: decimal("quantity", { precision: 10, scale: 4 }), quantity: decimal("quantity", { precision: 10, scale: 4 }),
unit: text("unit"), unit: text("unit"),
notes: text("notes"),
// Same free-text-or-null category slug as shoppingListItems.aisle — lets
// the pantry group items the same way the shopping list does. Null means
// "uncategorized", grouped under an "Other" bucket in the UI, not a
// migration gap (existing rows are never backfilled).
aisle: text("aisle"),
expiresAt: timestamp("expires_at"), expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
}, (t) => [ }, (t) => [
+32 -1
View File
@@ -1,5 +1,23 @@
import { db } from "./client"; import { db } from "./client";
import { tierDefinitions } from "./schema"; import { tierDefinitions, ingredients } from "./schema";
// Starter set of canonical ingredients + common EN/FR synonyms, so pantry
// items and recipe ingredients written differently ("sel", "sel fin", "sel
// de table", "table salt") can still be recognized as the same thing (see
// lib/ingredient-match.ts). Deliberately small — grows over time as gaps
// are found, not meant to be exhaustive on day one.
const STAPLE_INGREDIENTS: { name: string; aliases: string[]; category: string }[] = [
{ name: "salt", aliases: ["sel", "sel fin", "sel de table", "table salt", "fine salt", "sea salt", "sel de mer"], category: "spicesCondiments" },
{ name: "sugar", aliases: ["sucre", "sucre blanc", "white sugar", "granulated sugar", "sucre en poudre"], category: "pantry" },
{ name: "black pepper", aliases: ["pepper", "poivre", "poivre noir", "ground pepper", "poivre moulu"], category: "spicesCondiments" },
{ name: "flour", aliases: ["farine", "all-purpose flour", "farine de blé", "plain flour", "wheat flour"], category: "pantry" },
{ name: "butter", aliases: ["beurre", "unsalted butter", "beurre doux", "salted butter", "beurre demi-sel"], category: "dairyEggs" },
{ name: "milk", aliases: ["lait", "whole milk", "lait entier", "lait demi-écrémé"], category: "dairyEggs" },
{ name: "egg", aliases: ["eggs", "œuf", "oeuf", "œufs", "oeufs"], category: "dairyEggs" },
{ name: "onion", aliases: ["oignon", "oignons", "yellow onion", "onions"], category: "produce" },
{ name: "garlic", aliases: ["ail", "garlic clove", "gousse d'ail", "garlic cloves"], category: "produce" },
{ name: "olive oil", aliases: ["huile d'olive", "extra virgin olive oil", "huile d'olive vierge extra"], category: "pantry" },
];
async function seed() { async function seed() {
console.log("Seeding tier definitions..."); console.log("Seeding tier definitions...");
@@ -32,6 +50,19 @@ async function seed() {
]) ])
.onConflictDoNothing(); .onConflictDoNothing();
console.log("Seeding staple ingredients (name/alias matching)...");
await db
.insert(ingredients)
.values(
STAPLE_INGREDIENTS.map((i) => ({
id: crypto.randomUUID(),
name: i.name,
aliases: i.aliases,
category: i.category,
}))
)
.onConflictDoNothing({ target: ingredients.name });
console.log("Seed complete."); console.log("Seed complete.");
process.exit(0); process.exit(0);
} }