feat: mark recipe as cooked (multi-cook history + backdate) and enable auto-deduct pantry on cook (v0.75.0)

Adds a general-purpose "mark cooked" dialog for any recipe (not just batch-cook dishes), with a date picker for backdating and a pantry-deduct checkbox defaulted on. Also flips the previously dead-in-the-UI deductFromPantry flag to true for the existing batch-cook and meal-planner cook actions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 11:28:41 +02:00
parent 55c6fc5ab7
commit 04a911b431
14 changed files with 245 additions and 9 deletions
+8
View File
@@ -2,6 +2,14 @@
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.75.0 — 2026-07-24 11:30
### Added
- Mark any recipe as cooked (not just batch-cook dishes) — log the date, servings made, and whether to deduct matching ingredients from your pantry. Log the same recipe cooked as many times as you like; the recipe page shows how many times and when you last cooked it.
### Fixed
- Auto-deduct pantry on cook now actually fires — both existing mark-cooked flows (batch-cook dishes, meal-plan entries) previously hardcoded it off despite the underlying logic working correctly.
## 0.74.0 — 2026-07-24 10:30
### Added
+3 -2
View File
@@ -68,7 +68,8 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| Grocery delivery integration | **Partial / stub** | Generic export payload works (integrator-shaped — no visible in-app UI consumer besides the Instacart adapter); Instacart adapter is an explicit stub (requires a partnership agreement Epicure doesn't have — returns 501 if unconfigured, throws if "configured") | `apps/web/lib/grocery-providers/instacart.ts` |
| Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — |
| Pantry manual CRUD | Exists | Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow), undocumented until this pass | `apps/web/app/api/v1/pantry/**` |
| Auto-deduct pantry on cook | **API-only, dead in the UI** | The API logic is correct (name+unit matching, defaults `deductFromPantry: true`) — but both real UI callers (`batch-cook-dishes.tsx`, `meal-planner.tsx`'s mark-cooked flow) hardcode `deductFromPantry: false`. A home cook using the app today never triggers auto-deduction; it's only reachable via a direct API/API-key client. Previously documented as "Exists, one nuance," which overstated real-world behavior. | `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` |
| 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` |
| 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) |
| Expiring-soon tracking | Exists | With "use it up" recipe suggestions. The leftover-expiry push/email cron only covers batch-cook dishes — plain pantry items only get an in-app badge, no reminder. | `apps/web/components/pantry/expiring-soon-suggestions.tsx`, `apps/web/app/api/internal/cron/leftover-reminders/route.ts` |
| **Barcode scan (pantry)** | **Exists** | Real Open Food Facts API lookup by UPC/EAN | `apps/web/app/api/v1/pantry/scan/barcode` |
| Photo scan (pantry) | Exists | Vision-model based | `apps/web/app/api/v1/pantry/scan/photo` |
@@ -159,7 +160,7 @@ Ranked roughly by likely value:
9. **Offline coverage for shopping lists / meal plan** — currently recipe-viewing + mark-cooked only.
10. ~~Moderator-scoped admin routes~~ — closed 2026-07-21 (reports + recipe-unpublish).
11. **Followers-only recipes invisible on the author's own profile grid** (2026-07-24) — `u/[username]/page.tsx` only queries `visibility = "public"` for the grid, so a followers-only recipe shows up in a follower's feed but never on the profile it belongs to. Real bug, not a design choice.
12. **Auto-deduct pantry on cook is dead in the UI** (2026-07-24) — correct, tested API logic that literally nothing in the product surfaces; both UI callers hardcode it off. Either wire it up (a real feature a home cook would want) or stop pretending it's a shipped feature.
12. ~~Auto-deduct pantry on cook is dead in the UI~~ — closed 2026-07-24 (both UI callers now pass `true`; the new Mark Cooked dialog exposes it as a checkbox).
13. ~~Model Prefs picker is ungated~~ — closed 2026-07-24 (now behind BYOK access, hidden entirely for everyone else).
## Consumer-friendliness assessment — "is Epicure too tech-savvy?" (2026-07-24)
+19
View File
@@ -31,6 +31,7 @@ import { ServingScaler } from "@/components/recipe/serving-scaler";
import { FavoriteButton } from "@/components/social/favorite-button";
import { RatingStars } from "@/components/social/rating-stars";
import { CookedItReview } from "@/components/social/cooked-it-review";
import { MarkCookedSection } from "@/components/recipe/mark-cooked-section";
import { RecipeNotes } from "@/components/recipe/recipe-notes";
import { CommentsSection } from "@/components/social/comments-section";
import { getPublicUrl } from "@/lib/storage";
@@ -127,6 +128,12 @@ export default async function RecipePage({ params }: Params) {
}
}
// Non-batch cook log — batch-cook recipes track this per-dish instead
// (dishCookedAtMap above), logged via BatchCookDishes, not this list.
const plainCookLog = dishCookLog.filter((l) => !l.batchDishId);
const cookCount = plainCookLog.length;
const lastCookedAt = plainCookLog[0]?.cookedAt.toISOString() ?? null;
const avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null;
const ratingCount = ratingData[0]?.total ?? 0;
const isFavorited = !!favoriteData;
@@ -492,6 +499,18 @@ export default async function RecipePage({ params }: Params) {
</>
)}
{!recipe.isBatchCook && (
<>
<Separator />
<MarkCookedSection
recipeId={id}
baseServings={recipe.baseServings}
cookCount={cookCount}
lastCookedAt={lastCookedAt}
/>
</>
)}
{recipe.visibility !== "private" && (
<>
<Separator />
@@ -10,6 +10,9 @@ const Schema = z.object({
notes: z.string().max(2000).optional(),
deductFromPantry: z.boolean().default(true),
batchDishId: z.string().optional(),
/** ISO date (YYYY-MM-DD) or full datetime — lets a user log a past cook,
* not just "just now". Defaults to now when omitted. */
cookedAt: z.string().optional(),
});
export async function POST(req: NextRequest, { params }: Params) {
@@ -44,6 +47,8 @@ export async function POST(req: NextRequest, { params }: Params) {
isFirstBatchCook = !priorCook;
}
const cookedAt = data.cookedAt ? new Date(data.cookedAt) : new Date();
await db.insert(cookingHistory).values({
id: crypto.randomUUID(),
userId,
@@ -51,7 +56,7 @@ export async function POST(req: NextRequest, { params }: Params) {
batchDishId: data.batchDishId,
servings: data.servings,
notes: data.notes,
cookedAt: new Date(),
cookedAt: isNaN(cookedAt.getTime()) ? new Date() : cookedAt,
});
if (data.deductFromPantry && (!data.batchDishId || isFirstBatchCook)) {
@@ -377,7 +377,7 @@ export function MealPlanner({
const res = await fetch(`/api/v1/recipes/${entry.recipe.id}/cooked`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ servings: entry.servings, deductFromPantry: false }),
body: JSON.stringify({ servings: entry.servings, deductFromPantry: true }),
});
if (!res.ok) {
toast.error(t("markCookedFailed"));
@@ -34,7 +34,7 @@ export function BatchCookDishes({ recipeId, dishes: initialDishes }: { recipeId:
async function markCooked(dishId: string) {
setMarkingId(dishId);
const body = { batchDishId: dishId, deductFromPantry: false };
const body = { batchDishId: dishId, deductFromPantry: true };
try {
let res: Response;
try {
@@ -0,0 +1,119 @@
"use client";
import { cloneElement, isValidElement, useState, type ReactElement } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { ChefHat } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
interface MarkCookedDialogProps {
recipeId: string;
baseServings: number;
batchDishId?: string;
trigger: React.ReactNode;
onLogged?: (cookedAt: string) => void;
}
/** Logs a cook event — date, servings, and whether to deduct matching
* ingredients from the pantry (default on). A recipe can be logged as
* cooked any number of times; each submission is a new row, never an
* update. */
export function MarkCookedDialog({ recipeId, baseServings, batchDishId, trigger, onLogged }: MarkCookedDialogProps) {
const t = useTranslations("recipe");
const tCommon = useTranslations("common");
const [open, setOpen] = useState(false);
const [date, setDate] = useState(todayIso());
const [servings, setServings] = useState(baseServings);
const [deductFromPantry, setDeductFromPantry] = useState(true);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit() {
setSubmitting(true);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/cooked`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
servings,
cookedAt: date,
deductFromPantry,
...(batchDishId ? { batchDishId } : {}),
}),
});
if (!res.ok) throw new Error();
toast.success(t("markCookedSuccess"));
setOpen(false);
onLogged?.(date);
} catch {
toast.error(t("markCookedFailed"));
} finally {
setSubmitting(false);
}
}
const triggerElement = isValidElement(trigger)
? cloneElement(trigger as ReactElement<{ onClick?: () => void }>, { onClick: () => setOpen(true) })
: trigger;
return (
<>
{triggerElement}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ChefHat className="h-5 w-5 text-primary" />
{t("markCookedTitle")}
</DialogTitle>
<DialogDescription>{t("markCookedDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="mark-cooked-date">{t("markCookedDateLabel")}</Label>
<Input id="mark-cooked-date" type="date" value={date} max={todayIso()} onChange={(e) => setDate(e.target.value || todayIso())} />
</div>
<div className="space-y-2">
<Label htmlFor="mark-cooked-servings">{t("markCookedServingsLabel")}</Label>
<Input
id="mark-cooked-servings"
type="number"
min={1}
value={servings}
onChange={(e) => setServings(Math.max(1, Number(e.target.value) || baseServings))}
/>
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<Label htmlFor="mark-cooked-deduct">{t("markCookedDeductLabel")}</Label>
<p className="text-xs text-muted-foreground">{t("markCookedDeductDescription")}</p>
</div>
<Switch id="mark-cooked-deduct" checked={deductFromPantry} onCheckedChange={setDeductFromPantry} />
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={submitting}>
{tCommon("cancel")}
</Button>
<Button type="button" onClick={() => { void handleSubmit(); }} disabled={submitting}>
{submitting ? t("markCookedSaving") : t("markCookedSubmit")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,46 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { ChefHat } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n/provider";
import { MarkCookedDialog } from "./mark-cooked-dialog";
export function MarkCookedSection({
recipeId,
baseServings,
cookCount,
lastCookedAt,
}: {
recipeId: string;
baseServings: number;
cookCount: number;
lastCookedAt: string | null;
}) {
const t = useTranslations("recipe");
const router = useRouter();
const { locale } = useLocale();
return (
<div className="flex items-center gap-3">
<MarkCookedDialog
recipeId={recipeId}
baseServings={baseServings}
onLogged={() => router.refresh()}
trigger={
<Button type="button" variant="outline" size="sm">
<ChefHat className="h-3.5 w-3.5" />
{t("markCookedButton")}
</Button>
}
/>
{cookCount > 0 && (
<p className="text-xs text-muted-foreground">
{cookCount === 1 ? t("markCookedCountSingular") : t("markCookedCountPlural", { count: cookCount })}
{lastCookedAt && t("markCookedLast", { date: new Date(lastCookedAt).toLocaleDateString(locale, { month: "short", day: "numeric" }) })}
</p>
)}
</div>
);
}
+11 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.74.0";
export const APP_VERSION = "0.75.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,16 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.75.0",
date: "2026-07-24 11:30",
added: [
"Mark any recipe as cooked (not just batch-cook dishes) — log the date, servings made, and whether to deduct matching ingredients from your pantry. Log the same recipe cooked as many times as you like; the recipe page shows how many times and when you last cooked it.",
],
fixed: [
"Auto-deduct pantry on cook now actually fires — both existing mark-cooked flows (batch-cook dishes, meal-plan entries) previously hardcoded it off despite the underlying logic working correctly.",
],
},
{
version: "0.74.0",
date: "2026-07-24 10:30",
+1 -1
View File
@@ -290,7 +290,7 @@ 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: "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}/cooked", summary: "Log that you cooked this recipe", description: "Optionally deducts matching ingredients from your pantry.", 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() }) } } } }, 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: "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}/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: "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 } } } } });
+14
View File
@@ -147,6 +147,20 @@
"batchCookMarkSuccess": "Marked as cooked",
"batchCookMarkFailed": "Failed to mark as cooked",
"batchCookMarkQueuedOffline": "Saved offline — will sync when you're back online",
"markCookedButton": "Mark cooked",
"markCookedTitle": "Mark as cooked",
"markCookedDescription": "Log this cook — you can log the same recipe as cooked as many times as you like.",
"markCookedDateLabel": "Date",
"markCookedServingsLabel": "Servings made",
"markCookedDeductLabel": "Deduct from pantry",
"markCookedDeductDescription": "Remove matching ingredients you have on hand.",
"markCookedSubmit": "Mark cooked",
"markCookedSaving": "Saving…",
"markCookedSuccess": "Marked as cooked",
"markCookedFailed": "Failed to log this cook",
"markCookedCountSingular": "Cooked 1 time",
"markCookedCountPlural": "Cooked {count} times",
"markCookedLast": " · last {date}",
"batchCookExpiresOn": "Cooked — good until {date}",
"servings": "{count} servings",
"prep": "{mins}m prep",
+14
View File
@@ -147,6 +147,20 @@
"batchCookMarkSuccess": "Marqué comme cuisiné",
"batchCookMarkFailed": "Échec du marquage",
"batchCookMarkQueuedOffline": "Enregistré hors ligne — sera synchronisé au retour de la connexion",
"markCookedButton": "Marquer comme cuisiné",
"markCookedTitle": "Marquer comme cuisiné",
"markCookedDescription": "Enregistrez cette cuisson — vous pouvez marquer la même recette comme cuisinée autant de fois que vous le souhaitez.",
"markCookedDateLabel": "Date",
"markCookedServingsLabel": "Portions réalisées",
"markCookedDeductLabel": "Déduire du garde-manger",
"markCookedDeductDescription": "Retire les ingrédients correspondants que vous avez sous la main.",
"markCookedSubmit": "Marquer comme cuisiné",
"markCookedSaving": "Enregistrement…",
"markCookedSuccess": "Marqué comme cuisiné",
"markCookedFailed": "Échec de l'enregistrement",
"markCookedCountSingular": "Cuisiné 1 fois",
"markCookedCountPlural": "Cuisiné {count} fois",
"markCookedLast": " · dernière fois le {date}",
"batchCookExpiresOn": "Cuisiné — bon jusqu'au {date}",
"servings": "{count} portions",
"prep": "{mins}m prép.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.74.0",
"version": "0.75.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.74.0",
"version": "0.75.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",