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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } } } } });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.74.0",
|
||||
"version": "0.75.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user