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
+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 } } } } });