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.
|
||||
|
||||
## 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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -61,6 +61,11 @@ export default async function EditRecipePage({ params }: Params) {
|
||||
preview: getPublicUrl(photo.storageKey),
|
||||
})),
|
||||
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) => ({
|
||||
id: dish.id,
|
||||
name: dish.name,
|
||||
|
||||
@@ -396,7 +396,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
order: ing.order,
|
||||
}))}
|
||||
/>
|
||||
<NutritionPanel recipeId={id} initialData={recipe.nutritionData} />
|
||||
<NutritionPanel recipeId={id} initialData={recipe.nutritionData} initialManual={recipe.nutritionManual} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
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 });
|
||||
|
||||
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) {
|
||||
@@ -59,7 +59,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
);
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -56,6 +56,16 @@ const UpdateRecipeSchema = z.object({
|
||||
freezerNote: z.string().max(300).optional(),
|
||||
dayOfInstructions: z.string().min(1).max(1000),
|
||||
})).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 }> };
|
||||
@@ -159,6 +169,10 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
if (data.tags !== undefined) updates.tags = data.tags;
|
||||
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
|
||||
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));
|
||||
|
||||
|
||||
@@ -60,6 +60,17 @@ const CreateRecipeSchema = z.object({
|
||||
freezerNote: z.string().max(300).optional(),
|
||||
dayOfInstructions: z.string().min(1).max(1000),
|
||||
})).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) {
|
||||
@@ -132,6 +143,8 @@ export async function POST(req: NextRequest) {
|
||||
language: data.language,
|
||||
sourceUrl: data.sourceUrl,
|
||||
isBatchCook: data.isBatchCook,
|
||||
nutritionData: data.nutrition ? { perServing: data.nutrition } : undefined,
|
||||
nutritionManual: !!data.nutrition,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
@@ -19,11 +19,13 @@ type NutritionData = {
|
||||
interface NutritionPanelProps {
|
||||
recipeId: string;
|
||||
initialData?: NutritionData | null;
|
||||
initialManual?: boolean;
|
||||
}
|
||||
|
||||
export function NutritionPanel({ recipeId, initialData }: NutritionPanelProps) {
|
||||
export function NutritionPanel({ recipeId, initialData, initialManual }: NutritionPanelProps) {
|
||||
const t = useTranslations("nutritionPanel");
|
||||
const [nutrition, setNutrition] = useState<NutritionData | null>(initialData ?? null);
|
||||
const [manual, setManual] = useState(!!initialManual);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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 };
|
||||
setNutrition(data.nutrition);
|
||||
setManual(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("error"));
|
||||
} finally {
|
||||
@@ -69,9 +72,12 @@ export function NutritionPanel({ recipeId, initialData }: NutritionPanelProps) {
|
||||
disabled={loading}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{loading ? t("estimating") : t("reEstimateButton")}
|
||||
{loading ? t("estimating") : manual ? t("estimateInsteadButton") : t("reEstimateButton")}
|
||||
</Button>
|
||||
</div>
|
||||
{manual && !loading && (
|
||||
<p className="text-xs text-muted-foreground">{t("manualBadge")}</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</CardHeader>
|
||||
{nutrition && (
|
||||
|
||||
@@ -78,9 +78,28 @@ type RecipeFormProps = {
|
||||
photos?: PhotoEntry[];
|
||||
isBatchCook?: boolean;
|
||||
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 {
|
||||
return { id: crypto.randomUUID(), rawName: "", quantity: "", unit: "", note: "" };
|
||||
}
|
||||
@@ -98,6 +117,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
const t = useTranslations("recipeForm");
|
||||
const t_recipe = useTranslations("recipe");
|
||||
const t_common = useTranslations("common");
|
||||
const t_nutrition = useTranslations("nutritionPanel");
|
||||
const isEdit = !!recipeId;
|
||||
const id = recipeId ?? crypto.randomUUID();
|
||||
|
||||
@@ -113,6 +133,19 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const tagInputRef = useRef<HTMLInputElement>(null);
|
||||
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[]>(
|
||||
defaultValues?.ingredients?.length ? defaultValues.ingredients : [newIngredient()]
|
||||
);
|
||||
@@ -153,6 +186,8 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
isBatchCook,
|
||||
dishes,
|
||||
recipeType,
|
||||
addNutrition,
|
||||
nutrition,
|
||||
]);
|
||||
|
||||
// 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 })),
|
||||
isBatchCook,
|
||||
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";
|
||||
@@ -498,6 +543,81 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
<DietaryTagPicker value={dietaryTags} onChange={setDietaryTags} />
|
||||
</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" && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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 = {
|
||||
version: string;
|
||||
@@ -11,6 +11,13 @@ export type 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",
|
||||
date: "2026-07-14 18:05",
|
||||
|
||||
+17
-4
@@ -47,13 +47,23 @@ export function generateOpenApiSpec(): object {
|
||||
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({
|
||||
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"]),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
||||
prepMins: z.number().int().nullable(), cookMins: z.number().int().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(),
|
||||
ingredients: z.array(RecipeIngredientRef), steps: z.array(RecipeStepRef),
|
||||
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(),
|
||||
tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
||||
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(),
|
||||
}).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(),
|
||||
dayOfInstructions: z.string().min(1).max(1000),
|
||||
})).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({
|
||||
@@ -152,6 +164,7 @@ export function generateOpenApiSpec(): object {
|
||||
freezerNote: z.string().max(300).optional(),
|
||||
dayOfInstructions: z.string().min(1).max(1000),
|
||||
})).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() }));
|
||||
@@ -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/{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: "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: "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: "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.", 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: "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 } } } } });
|
||||
|
||||
@@ -477,7 +477,9 @@
|
||||
"carbs": "Carbs",
|
||||
"fat": "Fat",
|
||||
"fiber": "Fiber",
|
||||
"sodium": "Sodium"
|
||||
"sodium": "Sodium",
|
||||
"manualBadge": "Manually entered",
|
||||
"estimateInsteadButton": "Estimate with AI instead"
|
||||
},
|
||||
"nutritionDiary": {
|
||||
"title": "Nutrition Diary",
|
||||
@@ -709,6 +711,9 @@
|
||||
"tagsPlaceholder": "Add tags… (press Enter)",
|
||||
"removeTagAriaLabel": "Remove tag {tag}",
|
||||
"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",
|
||||
"ingredients": "Ingredients",
|
||||
"amount": "Amount",
|
||||
|
||||
@@ -477,7 +477,9 @@
|
||||
"carbs": "Glucides",
|
||||
"fat": "Lipides",
|
||||
"fiber": "Fibres",
|
||||
"sodium": "Sodium"
|
||||
"sodium": "Sodium",
|
||||
"manualBadge": "Saisie manuellement",
|
||||
"estimateInsteadButton": "Estimer avec l'IA à la place"
|
||||
},
|
||||
"nutritionDiary": {
|
||||
"title": "Journal nutritionnel",
|
||||
@@ -700,6 +702,9 @@
|
||||
"tagsPlaceholder": "Ajouter des tags… (Entrée)",
|
||||
"removeTagAriaLabel": "Supprimer le tag {tag}",
|
||||
"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",
|
||||
"ingredients": "Ingrédients",
|
||||
"amount": "Quantité",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.34.0",
|
||||
"version": "0.35.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.34.0",
|
||||
"version": "0.35.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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,
|
||||
"tag": "0039_sudden_franklin_storm",
|
||||
"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({}),
|
||||
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 } }>(),
|
||||
nutritionManual: boolean("nutrition_manual").notNull().default(false),
|
||||
difficulty: difficultyEnum("difficulty"),
|
||||
prepMins: integer("prep_mins"),
|
||||
cookMins: integer("cook_mins"),
|
||||
|
||||
Reference in New Issue
Block a user