feat: manual nutrition entry on recipe editor
Lets authors enter nutrition values by hand instead of relying only on the AI estimate; the detail-page panel now shows whether data was manually entered or AI-estimated and offers to switch between them. v0.35.0
This commit is contained in:
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
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.35.0 — 2026-07-14 18:20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Recipe editor now lets you enter nutrition values by hand (calories, protein, carbs, fat, fiber, sodium per serving) instead of relying only on the AI estimate — the nutrition panel shows a "Manually entered" badge and swaps to "Estimate with AI instead" when manual data is present.
|
||||||
|
|
||||||
## 0.34.0 — 2026-07-14 18:05
|
## 0.34.0 — 2026-07-14 18:05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export default async function EditRecipePage({ params }: Params) {
|
|||||||
preview: getPublicUrl(photo.storageKey),
|
preview: getPublicUrl(photo.storageKey),
|
||||||
})),
|
})),
|
||||||
isBatchCook: recipe.isBatchCook,
|
isBatchCook: recipe.isBatchCook,
|
||||||
|
// Only pre-fill the manual-nutrition editor with existing values when
|
||||||
|
// they were actually manually entered — AI-estimated data shouldn't
|
||||||
|
// silently become "manual" just because the recipe happened to have
|
||||||
|
// an estimate on file when it was opened for editing.
|
||||||
|
nutrition: recipe.nutritionManual ? recipe.nutritionData?.perServing ?? null : null,
|
||||||
dishes: recipe.batchDishes.map((dish) => ({
|
dishes: recipe.batchDishes.map((dish) => ({
|
||||||
id: dish.id,
|
id: dish.id,
|
||||||
name: dish.name,
|
name: dish.name,
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
order: ing.order,
|
order: ing.order,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<NutritionPanel recipeId={id} initialData={recipe.nutritionData} />
|
<NutritionPanel recipeId={id} initialData={recipe.nutritionData} initialManual={recipe.nutritionManual} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db, recipes, recipeIngredients } from "@epicure/db";
|
import { db, recipes } from "@epicure/db";
|
||||||
import { eq, and, or, ne } from "@epicure/db";
|
import { eq, and, or, ne } from "@epicure/db";
|
||||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
@@ -25,7 +25,7 @@ export async function GET(req: NextRequest, { params }: Params) {
|
|||||||
|
|
||||||
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
return NextResponse.json({ nutrition: recipe.nutritionData ?? null });
|
return NextResponse.json({ nutrition: recipe.nutritionData ?? null, manual: recipe.nutritionManual });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: Params) {
|
export async function POST(req: NextRequest, { params }: Params) {
|
||||||
@@ -59,7 +59,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
|
|
||||||
await db.update(recipes).set({ nutritionData: result.data }).where(and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)));
|
await db.update(recipes).set({ nutritionData: result.data, nutritionManual: false }).where(and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)));
|
||||||
|
|
||||||
return NextResponse.json({ nutrition: result.data });
|
return NextResponse.json({ nutrition: result.data });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ const UpdateRecipeSchema = z.object({
|
|||||||
freezerNote: z.string().max(300).optional(),
|
freezerNote: z.string().max(300).optional(),
|
||||||
dayOfInstructions: z.string().min(1).max(1000),
|
dayOfInstructions: z.string().min(1).max(1000),
|
||||||
})).max(10).optional(),
|
})).max(10).optional(),
|
||||||
|
// Manually-entered per-serving nutrition — set to null to clear it and
|
||||||
|
// re-enable the AI-estimate action on the recipe page.
|
||||||
|
nutrition: z.object({
|
||||||
|
calories: z.number().min(0).max(10000),
|
||||||
|
proteinG: z.number().min(0).max(1000),
|
||||||
|
carbsG: z.number().min(0).max(1000),
|
||||||
|
fatG: z.number().min(0).max(1000),
|
||||||
|
fiberG: z.number().min(0).max(1000),
|
||||||
|
sodiumMg: z.number().min(0).max(100000),
|
||||||
|
}).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
@@ -159,6 +169,10 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
|||||||
if (data.tags !== undefined) updates.tags = data.tags;
|
if (data.tags !== undefined) updates.tags = data.tags;
|
||||||
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
|
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
|
||||||
if (data.isBatchCook !== undefined) updates.isBatchCook = data.isBatchCook;
|
if (data.isBatchCook !== undefined) updates.isBatchCook = data.isBatchCook;
|
||||||
|
if (data.nutrition !== undefined) {
|
||||||
|
updates.nutritionData = data.nutrition ? { perServing: data.nutrition } : null;
|
||||||
|
updates.nutritionManual = !!data.nutrition;
|
||||||
|
}
|
||||||
|
|
||||||
await tx.update(recipes).set(updates).where(eq(recipes.id, id));
|
await tx.update(recipes).set(updates).where(eq(recipes.id, id));
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ const CreateRecipeSchema = z.object({
|
|||||||
freezerNote: z.string().max(300).optional(),
|
freezerNote: z.string().max(300).optional(),
|
||||||
dayOfInstructions: z.string().min(1).max(1000),
|
dayOfInstructions: z.string().min(1).max(1000),
|
||||||
})).max(10).default([]),
|
})).max(10).default([]),
|
||||||
|
// Manually-entered per-serving nutrition — takes precedence over (and
|
||||||
|
// disables) the AI-estimate action on the recipe page, since re-estimating
|
||||||
|
// would silently discard values the author entered on purpose.
|
||||||
|
nutrition: z.object({
|
||||||
|
calories: z.number().min(0).max(10000),
|
||||||
|
proteinG: z.number().min(0).max(1000),
|
||||||
|
carbsG: z.number().min(0).max(1000),
|
||||||
|
fatG: z.number().min(0).max(1000),
|
||||||
|
fiberG: z.number().min(0).max(1000),
|
||||||
|
sodiumMg: z.number().min(0).max(100000),
|
||||||
|
}).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
@@ -132,6 +143,8 @@ export async function POST(req: NextRequest) {
|
|||||||
language: data.language,
|
language: data.language,
|
||||||
sourceUrl: data.sourceUrl,
|
sourceUrl: data.sourceUrl,
|
||||||
isBatchCook: data.isBatchCook,
|
isBatchCook: data.isBatchCook,
|
||||||
|
nutritionData: data.nutrition ? { perServing: data.nutrition } : undefined,
|
||||||
|
nutritionManual: !!data.nutrition,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ type NutritionData = {
|
|||||||
interface NutritionPanelProps {
|
interface NutritionPanelProps {
|
||||||
recipeId: string;
|
recipeId: string;
|
||||||
initialData?: NutritionData | null;
|
initialData?: NutritionData | null;
|
||||||
|
initialManual?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NutritionPanel({ recipeId, initialData }: NutritionPanelProps) {
|
export function NutritionPanel({ recipeId, initialData, initialManual }: NutritionPanelProps) {
|
||||||
const t = useTranslations("nutritionPanel");
|
const t = useTranslations("nutritionPanel");
|
||||||
const [nutrition, setNutrition] = useState<NutritionData | null>(initialData ?? null);
|
const [nutrition, setNutrition] = useState<NutritionData | null>(initialData ?? null);
|
||||||
|
const [manual, setManual] = useState(!!initialManual);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ export function NutritionPanel({ recipeId, initialData }: NutritionPanelProps) {
|
|||||||
}
|
}
|
||||||
const data = await res.json() as { nutrition: NutritionData };
|
const data = await res.json() as { nutrition: NutritionData };
|
||||||
setNutrition(data.nutrition);
|
setNutrition(data.nutrition);
|
||||||
|
setManual(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("error"));
|
setError(err instanceof Error ? err.message : t("error"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -69,9 +72,12 @@ export function NutritionPanel({ recipeId, initialData }: NutritionPanelProps) {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="text-xs text-muted-foreground"
|
className="text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
{loading ? t("estimating") : t("reEstimateButton")}
|
{loading ? t("estimating") : manual ? t("estimateInsteadButton") : t("reEstimateButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{manual && !loading && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t("manualBadge")}</p>
|
||||||
|
)}
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{nutrition && (
|
{nutrition && (
|
||||||
|
|||||||
@@ -78,9 +78,28 @@ type RecipeFormProps = {
|
|||||||
photos?: PhotoEntry[];
|
photos?: PhotoEntry[];
|
||||||
isBatchCook?: boolean;
|
isBatchCook?: boolean;
|
||||||
dishes?: DishRow[];
|
dishes?: DishRow[];
|
||||||
|
nutrition?: {
|
||||||
|
calories: number;
|
||||||
|
proteinG: number;
|
||||||
|
carbsG: number;
|
||||||
|
fatG: number;
|
||||||
|
fiberG: number;
|
||||||
|
sodiumMg: number;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NutritionValues = {
|
||||||
|
calories: string;
|
||||||
|
proteinG: string;
|
||||||
|
carbsG: string;
|
||||||
|
fatG: string;
|
||||||
|
fiberG: string;
|
||||||
|
sodiumMg: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_NUTRITION: NutritionValues = { calories: "", proteinG: "", carbsG: "", fatG: "", fiberG: "", sodiumMg: "" };
|
||||||
|
|
||||||
function newIngredient(): IngredientRow {
|
function newIngredient(): IngredientRow {
|
||||||
return { id: crypto.randomUUID(), rawName: "", quantity: "", unit: "", note: "" };
|
return { id: crypto.randomUUID(), rawName: "", quantity: "", unit: "", note: "" };
|
||||||
}
|
}
|
||||||
@@ -98,6 +117,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
const t = useTranslations("recipeForm");
|
const t = useTranslations("recipeForm");
|
||||||
const t_recipe = useTranslations("recipe");
|
const t_recipe = useTranslations("recipe");
|
||||||
const t_common = useTranslations("common");
|
const t_common = useTranslations("common");
|
||||||
|
const t_nutrition = useTranslations("nutritionPanel");
|
||||||
const isEdit = !!recipeId;
|
const isEdit = !!recipeId;
|
||||||
const id = recipeId ?? crypto.randomUUID();
|
const id = recipeId ?? crypto.randomUUID();
|
||||||
|
|
||||||
@@ -113,6 +133,19 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
const [tagInput, setTagInput] = useState("");
|
const [tagInput, setTagInput] = useState("");
|
||||||
const tagInputRef = useRef<HTMLInputElement>(null);
|
const tagInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [dietaryTags, setDietaryTags] = useState<DietaryTags>(defaultValues?.dietaryTags ?? {});
|
const [dietaryTags, setDietaryTags] = useState<DietaryTags>(defaultValues?.dietaryTags ?? {});
|
||||||
|
const [addNutrition, setAddNutrition] = useState(!!defaultValues?.nutrition);
|
||||||
|
const [nutrition, setNutrition] = useState<NutritionValues>(
|
||||||
|
defaultValues?.nutrition
|
||||||
|
? {
|
||||||
|
calories: String(defaultValues.nutrition.calories),
|
||||||
|
proteinG: String(defaultValues.nutrition.proteinG),
|
||||||
|
carbsG: String(defaultValues.nutrition.carbsG),
|
||||||
|
fatG: String(defaultValues.nutrition.fatG),
|
||||||
|
fiberG: String(defaultValues.nutrition.fiberG),
|
||||||
|
sodiumMg: String(defaultValues.nutrition.sodiumMg),
|
||||||
|
}
|
||||||
|
: EMPTY_NUTRITION
|
||||||
|
);
|
||||||
const [ingredients, setIngredients] = useState<IngredientRow[]>(
|
const [ingredients, setIngredients] = useState<IngredientRow[]>(
|
||||||
defaultValues?.ingredients?.length ? defaultValues.ingredients : [newIngredient()]
|
defaultValues?.ingredients?.length ? defaultValues.ingredients : [newIngredient()]
|
||||||
);
|
);
|
||||||
@@ -153,6 +186,8 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
isBatchCook,
|
isBatchCook,
|
||||||
dishes,
|
dishes,
|
||||||
recipeType,
|
recipeType,
|
||||||
|
addNutrition,
|
||||||
|
nutrition,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Browser-level guard: warn on tab close / reload / external navigation while dirty.
|
// Browser-level guard: warn on tab close / reload / external navigation while dirty.
|
||||||
@@ -316,6 +351,16 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
photos: photos.map((p) => ({ key: p.key, isCover: p.isCover })),
|
photos: photos.map((p) => ({ key: p.key, isCover: p.isCover })),
|
||||||
isBatchCook,
|
isBatchCook,
|
||||||
dishes: filteredDishes,
|
dishes: filteredDishes,
|
||||||
|
nutrition: addNutrition
|
||||||
|
? {
|
||||||
|
calories: Number(nutrition.calories) || 0,
|
||||||
|
proteinG: Number(nutrition.proteinG) || 0,
|
||||||
|
carbsG: Number(nutrition.carbsG) || 0,
|
||||||
|
fatG: Number(nutrition.fatG) || 0,
|
||||||
|
fiberG: Number(nutrition.fiberG) || 0,
|
||||||
|
sodiumMg: Number(nutrition.sodiumMg) || 0,
|
||||||
|
}
|
||||||
|
: isEdit ? null : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const url = isEdit ? `/api/v1/recipes/${id}` : "/api/v1/recipes";
|
const url = isEdit ? `/api/v1/recipes/${id}` : "/api/v1/recipes";
|
||||||
@@ -498,6 +543,81 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
<DietaryTagPicker value={dietaryTags} onChange={setDietaryTags} />
|
<DietaryTagPicker value={dietaryTags} onChange={setDietaryTags} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Manual nutrition */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="add-nutrition" checked={addNutrition} onCheckedChange={setAddNutrition} />
|
||||||
|
<Label htmlFor="add-nutrition" className="cursor-pointer">{t("nutritionToggle")}</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("nutritionToggleHelp")}</p>
|
||||||
|
{addNutrition && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nutrition-calories">{t("calories")}</Label>
|
||||||
|
<Input
|
||||||
|
id="nutrition-calories"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={nutrition.calories}
|
||||||
|
onChange={(e) => setNutrition((prev) => ({ ...prev, calories: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nutrition-protein">{t_nutrition("protein")} (g)</Label>
|
||||||
|
<Input
|
||||||
|
id="nutrition-protein"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={nutrition.proteinG}
|
||||||
|
onChange={(e) => setNutrition((prev) => ({ ...prev, proteinG: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nutrition-carbs">{t_nutrition("carbs")} (g)</Label>
|
||||||
|
<Input
|
||||||
|
id="nutrition-carbs"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={nutrition.carbsG}
|
||||||
|
onChange={(e) => setNutrition((prev) => ({ ...prev, carbsG: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nutrition-fat">{t_nutrition("fat")} (g)</Label>
|
||||||
|
<Input
|
||||||
|
id="nutrition-fat"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={nutrition.fatG}
|
||||||
|
onChange={(e) => setNutrition((prev) => ({ ...prev, fatG: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nutrition-fiber">{t_nutrition("fiber")} (g)</Label>
|
||||||
|
<Input
|
||||||
|
id="nutrition-fiber"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={nutrition.fiberG}
|
||||||
|
onChange={(e) => setNutrition((prev) => ({ ...prev, fiberG: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nutrition-sodium">{t_nutrition("sodium")} (mg)</Label>
|
||||||
|
<Input
|
||||||
|
id="nutrition-sodium"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={nutrition.sodiumMg}
|
||||||
|
onChange={(e) => setNutrition((prev) => ({ ...prev, sodiumMg: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{recipeType === "dish" && (
|
{recipeType === "dish" && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@@ -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.34.0";
|
export const APP_VERSION = "0.35.0";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.35.0",
|
||||||
|
date: "2026-07-14 18:20",
|
||||||
|
added: [
|
||||||
|
"Recipe editor now lets you enter nutrition values by hand (calories, protein, carbs, fat, fiber, sodium per serving) instead of relying only on the AI estimate — the nutrition panel shows a \"Manually entered\" badge and swaps to \"Estimate with AI instead\" when manual data is present.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.34.0",
|
version: "0.34.0",
|
||||||
date: "2026-07-14 18:05",
|
date: "2026-07-14 18:05",
|
||||||
|
|||||||
+17
-4
@@ -47,13 +47,23 @@ export function generateOpenApiSpec(): object {
|
|||||||
dayOfInstructions: z.string(),
|
dayOfInstructions: z.string(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const NutritionInputRef = registry.register("NutritionInput", z.object({
|
||||||
|
calories: z.number().min(0).max(10000),
|
||||||
|
proteinG: z.number().min(0).max(1000),
|
||||||
|
carbsG: z.number().min(0).max(1000),
|
||||||
|
fatG: z.number().min(0).max(1000),
|
||||||
|
fiberG: z.number().min(0).max(1000),
|
||||||
|
sodiumMg: z.number().min(0).max(100000),
|
||||||
|
}).describe("Per serving."));
|
||||||
|
|
||||||
const RecipeRef = registry.register("Recipe", z.object({
|
const RecipeRef = registry.register("Recipe", z.object({
|
||||||
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
|
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
|
||||||
baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public"]),
|
baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public"]),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
||||||
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||||
tags: z.array(z.string()), sourceUrl: z.string().nullable(), language: z.string().nullable(),
|
tags: z.array(z.string()), sourceUrl: z.string().nullable(), language: z.string().nullable(),
|
||||||
isBatchCook: z.boolean(), nutritionData: z.record(z.string(), z.unknown()).nullable(),
|
isBatchCook: z.boolean(), nutritionData: z.object({ perServing: NutritionInputRef }).nullable(),
|
||||||
|
nutritionManual: z.boolean().describe("True if nutritionData was entered by the author rather than AI-estimated."),
|
||||||
dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
||||||
ingredients: z.array(RecipeIngredientRef), steps: z.array(RecipeStepRef),
|
ingredients: z.array(RecipeIngredientRef), steps: z.array(RecipeStepRef),
|
||||||
photos: z.array(RecipePhotoRef), batchDishes: z.array(RecipeBatchDishRef),
|
photos: z.array(RecipePhotoRef), batchDishes: z.array(RecipeBatchDishRef),
|
||||||
@@ -67,7 +77,8 @@ export function generateOpenApiSpec(): object {
|
|||||||
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||||
tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
||||||
language: z.string().nullable(), sourceUrl: z.string().nullable(),
|
language: z.string().nullable(), sourceUrl: z.string().nullable(),
|
||||||
isBatchCook: z.boolean(), nutritionData: z.record(z.string(), z.unknown()).nullable(),
|
isBatchCook: z.boolean(), nutritionData: z.object({ perServing: NutritionInputRef }).nullable(),
|
||||||
|
nutritionManual: z.boolean(),
|
||||||
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
||||||
}).describe("Recipe list rows — no ingredients/steps/photos/batchDishes (unlike the single-recipe GET)."));
|
}).describe("Recipe list rows — no ingredients/steps/photos/batchDishes (unlike the single-recipe GET)."));
|
||||||
|
|
||||||
@@ -111,6 +122,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
freezerNote: z.string().max(300).optional(),
|
freezerNote: z.string().max(300).optional(),
|
||||||
dayOfInstructions: z.string().min(1).max(1000),
|
dayOfInstructions: z.string().min(1).max(1000),
|
||||||
})).max(10).default([]),
|
})).max(10).default([]),
|
||||||
|
nutrition: NutritionInputRef.optional().describe("Manually-entered nutrition — sets nutritionManual and disables the AI-estimate action on the recipe page."),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const UpdateRecipeRef = registry.register("UpdateRecipe", z.object({
|
const UpdateRecipeRef = registry.register("UpdateRecipe", z.object({
|
||||||
@@ -152,6 +164,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
freezerNote: z.string().max(300).optional(),
|
freezerNote: z.string().max(300).optional(),
|
||||||
dayOfInstructions: z.string().min(1).max(1000),
|
dayOfInstructions: z.string().min(1).max(1000),
|
||||||
})).max(10).optional(),
|
})).max(10).optional(),
|
||||||
|
nutrition: NutritionInputRef.nullable().optional().describe("Set to null to clear manually-entered nutrition and re-enable the AI-estimate action."),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const ApiErrorRef = registry.register("ApiError", z.object({ error: z.string() }));
|
const ApiErrorRef = registry.register("ApiError", z.object({ error: z.string() }));
|
||||||
@@ -268,8 +281,8 @@ export function generateOpenApiSpec(): object {
|
|||||||
registry.registerPath({ method: "post", path: "/api/v1/recipes/bulk/export", summary: "Export several recipes as one Markdown document", 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 } } }, 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", 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 } } }, 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.", 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: "get", path: "/api/v1/recipes/{id}/nutrition", summary: "Get cached nutrition estimate", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data or null", content: { "application/json": { schema: z.object({ nutrition: z.record(z.string(), z.unknown()).nullable() }) } } }, 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.", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data", content: { "application/json": { schema: z.object({ nutrition: z.record(z.string(), z.unknown()) }) } } }, 401: { description: "Unauthorized", 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.", 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 } } }, 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 } } } } });
|
||||||
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}/notes", summary: "Set (or clear, with empty content) your private note", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().max(5000) }) } }, required: true } }, responses: { 200: { description: "Saved", 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: "put", path: "/api/v1/recipes/{id}/notes", summary: "Set (or clear, with empty content) your private note", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().max(5000) }) } }, required: true } }, responses: { 200: { description: "Saved", 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}/reviews", summary: "List reviews with text or a photo (capped at 50)", security, request: { params: idParam }, responses: { 200: { description: "Reviews", content: { "application/json": { schema: z.object({ data: z.array(RecipeReviewRef) }) } } }, 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}/reviews", summary: "List reviews with text or a photo (capped at 50)", security, request: { params: idParam }, responses: { 200: { description: "Reviews", content: { "application/json": { schema: z.object({ data: z.array(RecipeReviewRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
|||||||
@@ -477,7 +477,9 @@
|
|||||||
"carbs": "Carbs",
|
"carbs": "Carbs",
|
||||||
"fat": "Fat",
|
"fat": "Fat",
|
||||||
"fiber": "Fiber",
|
"fiber": "Fiber",
|
||||||
"sodium": "Sodium"
|
"sodium": "Sodium",
|
||||||
|
"manualBadge": "Manually entered",
|
||||||
|
"estimateInsteadButton": "Estimate with AI instead"
|
||||||
},
|
},
|
||||||
"nutritionDiary": {
|
"nutritionDiary": {
|
||||||
"title": "Nutrition Diary",
|
"title": "Nutrition Diary",
|
||||||
@@ -709,6 +711,9 @@
|
|||||||
"tagsPlaceholder": "Add tags… (press Enter)",
|
"tagsPlaceholder": "Add tags… (press Enter)",
|
||||||
"removeTagAriaLabel": "Remove tag {tag}",
|
"removeTagAriaLabel": "Remove tag {tag}",
|
||||||
"dietaryTags": "Dietary tags",
|
"dietaryTags": "Dietary tags",
|
||||||
|
"nutritionToggle": "Add nutrition info manually",
|
||||||
|
"nutritionToggleHelp": "Per serving. Turns off the AI-estimate action on the recipe page — clear these fields (turn this off) to let AI estimate it instead.",
|
||||||
|
"calories": "Calories (kcal)",
|
||||||
"photos": "Photos",
|
"photos": "Photos",
|
||||||
"ingredients": "Ingredients",
|
"ingredients": "Ingredients",
|
||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
|
|||||||
@@ -477,7 +477,9 @@
|
|||||||
"carbs": "Glucides",
|
"carbs": "Glucides",
|
||||||
"fat": "Lipides",
|
"fat": "Lipides",
|
||||||
"fiber": "Fibres",
|
"fiber": "Fibres",
|
||||||
"sodium": "Sodium"
|
"sodium": "Sodium",
|
||||||
|
"manualBadge": "Saisie manuellement",
|
||||||
|
"estimateInsteadButton": "Estimer avec l'IA à la place"
|
||||||
},
|
},
|
||||||
"nutritionDiary": {
|
"nutritionDiary": {
|
||||||
"title": "Journal nutritionnel",
|
"title": "Journal nutritionnel",
|
||||||
@@ -700,6 +702,9 @@
|
|||||||
"tagsPlaceholder": "Ajouter des tags… (Entrée)",
|
"tagsPlaceholder": "Ajouter des tags… (Entrée)",
|
||||||
"removeTagAriaLabel": "Supprimer le tag {tag}",
|
"removeTagAriaLabel": "Supprimer le tag {tag}",
|
||||||
"dietaryTags": "Tags alimentaires",
|
"dietaryTags": "Tags alimentaires",
|
||||||
|
"nutritionToggle": "Ajouter les valeurs nutritionnelles manuellement",
|
||||||
|
"nutritionToggleHelp": "Par portion. Désactive l'estimation IA sur la page de la recette — videz ces champs (désactivez ceci) pour laisser l'IA l'estimer à nouveau.",
|
||||||
|
"calories": "Calories (kcal)",
|
||||||
"photos": "Photos",
|
"photos": "Photos",
|
||||||
"ingredients": "Ingrédients",
|
"ingredients": "Ingrédients",
|
||||||
"amount": "Quantité",
|
"amount": "Quantité",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.34.0",
|
"version": "0.35.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.34.0",
|
"version": "0.35.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "recipes" ADD COLUMN "nutrition_manual" boolean DEFAULT false NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -281,6 +281,13 @@
|
|||||||
"when": 1784030221865,
|
"when": 1784030221865,
|
||||||
"tag": "0039_sudden_franklin_storm",
|
"tag": "0039_sudden_franklin_storm",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 40,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784036519678,
|
||||||
|
"tag": "0040_loving_spot",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,7 @@ export const recipes = pgTable("recipes", {
|
|||||||
dietaryTags: jsonb("dietary_tags").$type<DietaryTags>().default({}),
|
dietaryTags: jsonb("dietary_tags").$type<DietaryTags>().default({}),
|
||||||
dietaryVerified: boolean("dietary_verified").notNull().default(false),
|
dietaryVerified: boolean("dietary_verified").notNull().default(false),
|
||||||
nutritionData: jsonb("nutrition_data").$type<{ perServing: { calories: number; proteinG: number; carbsG: number; fatG: number; fiberG: number; sodiumMg: number } }>(),
|
nutritionData: jsonb("nutrition_data").$type<{ perServing: { calories: number; proteinG: number; carbsG: number; fatG: number; fiberG: number; sodiumMg: number } }>(),
|
||||||
|
nutritionManual: boolean("nutrition_manual").notNull().default(false),
|
||||||
difficulty: difficultyEnum("difficulty"),
|
difficulty: difficultyEnum("difficulty"),
|
||||||
prepMins: integer("prep_mins"),
|
prepMins: integer("prep_mins"),
|
||||||
cookMins: integer("cook_mins"),
|
cookMins: integer("cook_mins"),
|
||||||
|
|||||||
Reference in New Issue
Block a user