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:
Arnaud
2026-07-14 15:55:42 +02:00
parent bd3d8c88f0
commit 1577b8de01
18 changed files with 5275 additions and 15 deletions
+5
View File
@@ -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,
+1 -1
View File
@@ -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 });
}
+14
View File
@@ -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));
+13
View File
@@ -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 && (
+120
View File
@@ -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 />
+8 -1
View File
@@ -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
View File
@@ -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 } } } } });
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.34.0",
"version": "0.35.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -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
}
]
}
+1
View File
@@ -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"),