diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c91c1..7d71722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.83.0 — 2026-07-24 19:00 + +### Added +- Pantry items can now have notes and a category, with the list grouped into collapsible category sections like the shopping list. +- Ingredient-alias matching: pantry items, recipe ingredients, and shopping-list generation now recognize that "sel", "sel fin", and "table salt" are the same ingredient (seeded with ~10 common EN/FR staples) — improves can-cook scoring, auto-deduct-on-cook accuracy, and pantry-awareness when generating a shopping list. +- A "Merge duplicates" button in the pantry cleans up items that turn out to be the same ingredient under a different name, summing quantities where possible. +- Cook log entries (from "Mark cooked") can now be edited and deleted, not just created. The "Cooked N times" text is a hover tooltip listing every date, and opens a full history sheet on click. +- The "Forked by N others" backlink on a recipe page is now a click-to-open popover instead of an inline list, so a heavily-forked recipe doesn't grow a long list directly on the page. + +### Fixed +- Pantry and shopping-list quantities were displayed with their full stored precision (e.g. "0.3333 kg", "2.0000 kg") everywhere — in-app, print views, and Markdown exports. Now rounded/fraction-formatted consistently with recipe ingredient display. + ## 0.82.0 — 2026-07-24 17:45 ### Added diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md index bb1efe6..da1bf1e 100644 --- a/FEATURE_AUDIT.md +++ b/FEATURE_AUDIT.md @@ -17,7 +17,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real | Feature | Status | Description | Key files | |---|---|---|---| | Recipe CRUD | Exists | Create/get/list/update/delete; update snapshots the prior version first (`recipeSnapshots`) | `apps/web/app/api/v1/recipes/**` | -| Fork / duplicate | Exists | Same backend action for both — UI just swaps the label based on ownership. Backlink works both directions: a forked recipe already showed "Forked from X"; the original now also shows "Forked by N others" with links to each — filtered to forks that are public/unlisted or belong to the viewer, so a private fork's existence/title never leaks to other viewers. Uses the same `recipeVariations` table as AI variations/adapt, not a separate fork-tracking mechanism. | `apps/web/app/api/v1/recipes/[id]/fork/route.ts`, `apps/web/app/(app)/recipes/[id]/page.tsx` | +| Fork / duplicate | Exists | Same backend action for both — UI just swaps the label based on ownership. Backlink works both directions: a forked recipe already showed "Forked from X"; the original now also shows "Forked by N others" as a click-to-open popover (list stays out of the page flow instead of a potentially long inline row) — filtered to forks that are public/unlisted or belong to the viewer, so a private fork's existence/title never leaks to other viewers. Uses the same `recipeVariations` table as AI variations/adapt, not a separate fork-tracking mechanism. | `apps/web/app/api/v1/recipes/[id]/fork/route.ts`, `apps/web/app/(app)/recipes/[id]/page.tsx`, `apps/web/components/recipe/forked-by-popover.tsx` | | Version history | Exists | `GET /recipes/[id]/versions`, diff-able snapshots | `apps/web/app/api/v1/recipes/[id]/versions/**` | | **Import from URL** | **Exists** | Fetches a page (SSRF-checked), strips markup, extracts a structured recipe via AI. Returns the extraction to the client — doesn't self-persist, caller POSTs it to create | `apps/web/app/api/v1/ai/import-url` | | Import from photo | Exists | Two-stage vision→text; recognizes a photographed page/handwritten recipe, self-persists as a private recipe | `apps/web/app/api/v1/ai/import-photo`, `lib/ai/features/{recognize-photo,generate-recipe-from-recognition}.ts` | @@ -69,8 +69,12 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real | Aisle categorization | Exists | Heuristic auto-assign + bulk re-categorize, plus inline custom-category creation and full drag-and-drop reordering (richer UI than previously documented) | `apps/web/lib/grocery-categories.ts` | | Grocery delivery integration | **Partial / stub** | Generic export payload works (integrator-shaped — no visible in-app UI consumer besides the Instacart adapter); Instacart adapter is an explicit stub (requires a partnership agreement Epicure doesn't have — returns 501 if unconfigured, throws if "configured") | `apps/web/lib/grocery-providers/instacart.ts` | | Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — | -| Pantry manual CRUD | Exists | Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow), undocumented until this pass | `apps/web/app/api/v1/pantry/**` | -| Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` | +| Pantry manual CRUD | Exists (extended 2026-07-24) | Full edit dialog added (previously add+delete only, despite the API already supporting `PUT`) — name/quantity/unit/expiry plus two new fields, **notes** (free text) and **category** (same `GROCERY_CATEGORIES` slugs shopping lists use). List now groups into collapsible-by-category sections when more than one category is present, mirroring the shopping list's grouping UX (without the drag-reorder — pantry has no manual ordering need). Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow). | `apps/web/app/api/v1/pantry/**`, `apps/web/components/meal-plan/pantry-manager.tsx`, `apps/web/components/pantry/pantry-item-dialog.tsx` | +| Ingredient alias matching (new 2026-07-24) | Exists | The `ingredients` table (canonical name + `aliases[]`) existed but was never populated or queried anywhere. Seeded with ~10 bilingual EN/FR staples (salt, sugar, pepper, flour, butter, milk, egg, onion, garlic, olive oil — `packages/db/src/seed.ts`) and wired into every place that compares ingredient names by raw text: pantry add/edit (sets `ingredientId` when a name/alias matches), the can-cook / "use it up soon" scorer, auto-deduct-on-cook, and shopping-list pantry-awareness on generation. Resolution happens at compare-time from free text (`resolveIngredientKey`), not from a stored FK on both sides — recipe ingredients still don't carry `ingredientId`. | `apps/web/lib/ingredient-match.ts`, `packages/db/src/seed.ts` | +| Merge duplicate pantry items (new 2026-07-24) | Exists | One-shot cleanup for pre-existing pantry rows that are the same ingredient under different names (added before alias matching existed) — groups by resolved ingredient key + normalized unit, sums quantities only when every row in a group has a parseable one (otherwise keeps the first known amount rather than guessing), concatenates notes, keeps the soonest expiry. Manual trigger (a button in the pantry toolbar), not automatic — manual single-item add still always inserts a new row rather than silently merging, since two batches of the same ingredient can have different expiry dates worth tracking separately. | `apps/web/app/api/v1/pantry/merge-duplicates/route.ts` | +| Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. Matching now goes through the ingredient-alias resolver, not a raw name string compare. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` | +| Cook log edit/delete (new 2026-07-24) | Exists | Plain (non-batch) cook-log entries can now be listed, edited (date/servings/notes), and removed — previously log-once, no way to fix a mistake or remove a duplicate entry. The recipe page's "Cooked N times" text is a hover tooltip (up to 8 dates, "+N more" beyond) that also opens a full manage sheet on click. Editing/deleting never touches pantry quantities — a deduction from when the entry was created isn't reversed or reapplied. | `apps/web/app/api/v1/recipes/[id]/cooked/[logId]/route.ts`, `apps/web/components/recipe/{mark-cooked-section,edit-cook-log-dialog}.tsx` | +| Quantity display precision (closed 2026-07-24) | Exists | Pantry and shopping-list quantities are stored as `decimal(10,4)` and were displayed as the raw string (e.g. "0.3333 kg", "2.0000 kg") everywhere: in-app lists, print views, and Markdown exports. All 6 spots now go through `formatQuantity` (fraction-aware rounding, already used for recipe ingredient scaling) instead of the raw column value. | `apps/web/lib/fractions.ts`, `apps/web/components/meal-plan/{pantry-manager,shopping-list-view}.tsx`, `apps/web/app/print/{pantry,shopping-list/[id]}/page.tsx`, `apps/web/lib/markdown/{pantry,shopping-list}.ts` | | Mark recipe as cooked (any recipe, not just batch-cook) | Exists (new, 2026-07-24) | A recipe can be logged as cooked any number of times — each log is a new `cookingHistory` row, no unique constraint, this was already true at the schema level, just not exposed for plain (non-batch) recipes before. New dialog on the recipe page: date (defaults today, can backdate), servings, and a deduct-from-pantry toggle (default on). Shows a "cooked N times · last DATE" indicator once logged. | `apps/web/components/recipe/{mark-cooked-dialog,mark-cooked-section}.tsx`, `apps/web/app/api/v1/recipes/[id]/cooked/route.ts` (`cookedAt` field, new) | | Billing/invoice details (full name, address, phone) | Exists (new, 2026-07-24) | New `userBillingDetails` table (1:1 with `users`), separate from the display `name` field. `fullName` is required by the form/API but nullable at the DB level (no backfill for existing users); address lines/city/postal code/country/phone are all optional. Lives under `Settings → Billing`, feeds future invoice generation — not wired into Stripe yet. | `packages/db/src/schema/billing.ts` (`userBillingDetails`), `apps/web/app/api/v1/users/me/billing-details/route.ts`, `apps/web/components/settings/billing-details-form.tsx` | | Post-signup onboarding wizard | Exists (new, 2026-07-24) | 3-step wizard (welcome → dietary/allergen prefs → notification opt-in) shown once per account, right after the app shell layout loads for a user with no `onboardingCompletedAt`. Skippable at every step; existing accounts were backfilled as already-onboarded so the wizard only appears for new signups. Also closes a real pre-existing gap: `userAllergens` had a table and a GDPR-export read, but **no write path at all** — this ships the first one. Dietary tags are a new `users.dietaryTags` jsonb column (mirrors `recipes.dietaryTags`'s 7-tag shape); neither is yet consumed by AI generation or recipe matching. | `apps/web/app/onboarding/page.tsx`, `apps/web/components/onboarding/onboarding-wizard.tsx`, `apps/web/app/api/v1/users/me/onboarding/route.ts`, `apps/web/app/(app)/layout.tsx` (redirect gate) | diff --git a/apps/web/app/(app)/pantry/page.tsx b/apps/web/app/(app)/pantry/page.tsx index 04de42a..b93dcaa 100644 --- a/apps/web/app/(app)/pantry/page.tsx +++ b/apps/web/app/(app)/pantry/page.tsx @@ -11,6 +11,7 @@ import { scoreRecipesAgainstPantry } from "@/lib/pantry-match"; import { dishExpiresAt, daysUntil, isLeftoverExpiringSoon } from "@/lib/leftover-match"; import { getPublicUrl } from "@/lib/storage"; import { getFeatureFlagMatrix, isFeatureAvailableAnyTier, type Tier } from "@/lib/feature-flags"; +import { loadIngredientAliasIndex } from "@/lib/ingredient-match"; export const metadata: Metadata = {}; @@ -22,7 +23,7 @@ export default async function PantryPage() { const markdownExportLocked = !featureFlags.markdown_export[viewerTier]; const markdownExportAvailable = isFeatureAvailableAnyTier(featureFlags, "markdown_export"); - const [items, candidateRecipes, cookedDishes] = await Promise.all([ + const [items, candidateRecipes, cookedDishes, aliasIndex] = await Promise.all([ db.query.pantryItems.findMany({ where: eq(pantryItems.userId, session.user.id), orderBy: asc(pantryItems.rawName), @@ -38,6 +39,7 @@ export default async function PantryPage() { recipe: { columns: { id: true, title: true } }, }, }), + loadIngredientAliasIndex(), ]); const mappedItems = items.map((i) => ({ @@ -45,10 +47,12 @@ export default async function PantryPage() { rawName: i.rawName, quantity: i.quantity, unit: i.unit, + notes: i.notes, + aisle: i.aisle, expiresAt: i.expiresAt?.toISOString() ?? null, })); - const suggestions = scoreRecipesAgainstPantry(candidateRecipes, items) + const suggestions = scoreRecipesAgainstPantry(candidateRecipes, items, aliasIndex) .filter((s) => s.usesExpiring.length > 0) .slice(0, 3) .map((s) => { diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index 0acc398..ccfe7c2 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -14,6 +14,7 @@ import { PrintButton } from "@/components/recipe/print-button"; import { ShareRecipeButton } from "@/components/recipe/share-recipe-button"; import { SaveOfflineButton } from "@/components/recipe/save-offline-button"; import { VersionHistoryButton } from "@/components/recipe/version-history-button"; +import { ForkedByPopover } from "@/components/recipe/forked-by-popover"; import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button"; import { ForkRecipeButton } from "@/components/recipe/fork-recipe-button"; import { NutritionPanel } from "@/components/recipe/nutrition-panel"; @@ -109,7 +110,7 @@ export default async function RecipePage({ params }: Params) { db.query.cookingHistory.findMany({ where: and(eq(cookingHistory.recipeId, id), eq(cookingHistory.userId, session.user.id)), orderBy: desc(cookingHistory.cookedAt), - columns: { batchDishId: true, cookedAt: true }, + columns: { id: true, batchDishId: true, cookedAt: true, servings: true, notes: true }, }), getFeatureFlagMatrix(), getFeaturePrefs(session.user.id), @@ -156,9 +157,9 @@ export default async function RecipePage({ params }: Params) { // Non-batch cook log — batch-cook recipes track this per-dish instead // (dishCookedAtMap above), logged via BatchCookDishes, not this list. - const plainCookLog = dishCookLog.filter((l) => !l.batchDishId); - const cookCount = plainCookLog.length; - const lastCookedAt = plainCookLog[0]?.cookedAt.toISOString() ?? null; + const plainCookLog = dishCookLog + .filter((l) => !l.batchDishId) + .map((l) => ({ id: l.id, cookedAt: l.cookedAt.toISOString(), servings: l.servings, notes: l.notes })); const avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null; const ratingCount = ratingData[0]?.total ?? 0; @@ -209,24 +210,14 @@ export default async function RecipePage({ params }: Params) { )} {visibleForks.length > 0 && ( -
- {visibleForks.length === 1
+
{t("expiresOn", { date: new Date(item.expiresAt).toLocaleDateString() })}
+ )} + {item.notes &&{item.notes}
} +{t("expiresOn", { date: new Date(item.expiresAt).toLocaleDateString() })}
- )} -{t("cookLogMore", { count: logs.length - TOOLTIP_DATE_LIMIT })}
+ )} +{t("cookLogEmpty")}
} + {logs.map((log) => ( +{formatDate(log.cookedAt)}
+ {log.servings &&{t("markCookedServingsLabel")}: {log.servings}
} + {log.notes &&{log.notes}
} +