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:
@@ -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.
|
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
|
## 0.74.0 — 2026-07-24 10:30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+3
-2
@@ -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` |
|
| 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 | 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` |
|
| 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` |
|
| **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` |
|
| 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.
|
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).
|
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.
|
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).
|
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)
|
## Consumer-friendliness assessment — "is Epicure too tech-savvy?" (2026-07-24)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { ServingScaler } from "@/components/recipe/serving-scaler";
|
|||||||
import { FavoriteButton } from "@/components/social/favorite-button";
|
import { FavoriteButton } from "@/components/social/favorite-button";
|
||||||
import { RatingStars } from "@/components/social/rating-stars";
|
import { RatingStars } from "@/components/social/rating-stars";
|
||||||
import { CookedItReview } from "@/components/social/cooked-it-review";
|
import { CookedItReview } from "@/components/social/cooked-it-review";
|
||||||
|
import { MarkCookedSection } from "@/components/recipe/mark-cooked-section";
|
||||||
import { RecipeNotes } from "@/components/recipe/recipe-notes";
|
import { RecipeNotes } from "@/components/recipe/recipe-notes";
|
||||||
import { CommentsSection } from "@/components/social/comments-section";
|
import { CommentsSection } from "@/components/social/comments-section";
|
||||||
import { getPublicUrl } from "@/lib/storage";
|
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 avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null;
|
||||||
const ratingCount = ratingData[0]?.total ?? 0;
|
const ratingCount = ratingData[0]?.total ?? 0;
|
||||||
const isFavorited = !!favoriteData;
|
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" && (
|
{recipe.visibility !== "private" && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const Schema = z.object({
|
|||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
deductFromPantry: z.boolean().default(true),
|
deductFromPantry: z.boolean().default(true),
|
||||||
batchDishId: z.string().optional(),
|
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) {
|
export async function POST(req: NextRequest, { params }: Params) {
|
||||||
@@ -44,6 +47,8 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
isFirstBatchCook = !priorCook;
|
isFirstBatchCook = !priorCook;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cookedAt = data.cookedAt ? new Date(data.cookedAt) : new Date();
|
||||||
|
|
||||||
await db.insert(cookingHistory).values({
|
await db.insert(cookingHistory).values({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
@@ -51,7 +56,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
batchDishId: data.batchDishId,
|
batchDishId: data.batchDishId,
|
||||||
servings: data.servings,
|
servings: data.servings,
|
||||||
notes: data.notes,
|
notes: data.notes,
|
||||||
cookedAt: new Date(),
|
cookedAt: isNaN(cookedAt.getTime()) ? new Date() : cookedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.deductFromPantry && (!data.batchDishId || isFirstBatchCook)) {
|
if (data.deductFromPantry && (!data.batchDishId || isFirstBatchCook)) {
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ export function MealPlanner({
|
|||||||
const res = await fetch(`/api/v1/recipes/${entry.recipe.id}/cooked`, {
|
const res = await fetch(`/api/v1/recipes/${entry.recipe.id}/cooked`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ servings: entry.servings, deductFromPantry: false }),
|
body: JSON.stringify({ servings: entry.servings, deductFromPantry: true }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error(t("markCookedFailed"));
|
toast.error(t("markCookedFailed"));
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function BatchCookDishes({ recipeId, dishes: initialDishes }: { recipeId:
|
|||||||
|
|
||||||
async function markCooked(dishId: string) {
|
async function markCooked(dishId: string) {
|
||||||
setMarkingId(dishId);
|
setMarkingId(dishId);
|
||||||
const body = { batchDishId: dishId, deductFromPantry: false };
|
const body = { batchDishId: dishId, deductFromPantry: true };
|
||||||
try {
|
try {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
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.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,16 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.74.0",
|
||||||
date: "2026-07-24 10:30",
|
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: "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.", 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: "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 } } } } });
|
||||||
|
|||||||
@@ -147,6 +147,20 @@
|
|||||||
"batchCookMarkSuccess": "Marked as cooked",
|
"batchCookMarkSuccess": "Marked as cooked",
|
||||||
"batchCookMarkFailed": "Failed to mark as cooked",
|
"batchCookMarkFailed": "Failed to mark as cooked",
|
||||||
"batchCookMarkQueuedOffline": "Saved offline — will sync when you're back online",
|
"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}",
|
"batchCookExpiresOn": "Cooked — good until {date}",
|
||||||
"servings": "{count} servings",
|
"servings": "{count} servings",
|
||||||
"prep": "{mins}m prep",
|
"prep": "{mins}m prep",
|
||||||
|
|||||||
@@ -147,6 +147,20 @@
|
|||||||
"batchCookMarkSuccess": "Marqué comme cuisiné",
|
"batchCookMarkSuccess": "Marqué comme cuisiné",
|
||||||
"batchCookMarkFailed": "Échec du marquage",
|
"batchCookMarkFailed": "Échec du marquage",
|
||||||
"batchCookMarkQueuedOffline": "Enregistré hors ligne — sera synchronisé au retour de la connexion",
|
"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}",
|
"batchCookExpiresOn": "Cuisiné — bon jusqu'au {date}",
|
||||||
"servings": "{count} portions",
|
"servings": "{count} portions",
|
||||||
"prep": "{mins}m prép.",
|
"prep": "{mins}m prép.",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.74.0",
|
"version": "0.75.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.74.0",
|
"version": "0.75.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user