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:
@@ -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
@@ -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) |
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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 } } } } });
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user