From 741daebbe275b9737d05fef062cc3c7f130b9330 Mon Sep 17 00:00:00 2001 From: Oddvar G Date: Tue, 13 Oct 2026 13:24:34 +0000 Subject: [PATCH] feat(recipes): add estimated carbohydrates per serving The Epicurious dataset has no carbohydrate values, so the import now estimates them from calories, protein and fat using the Atwater factors (4/4/9 kcal per gram). Recipes with missing or contradictory macros get null instead of a misleading 0. Stored in a new indexed `carbs` column, exposed on the GraphQL Recipe type and shown as "Carbs (est.)" on the detail page. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++ backend/db/schema.sql | 2 ++ backend/scripts/import.ts | 6 ++-- backend/scripts/transform.test.ts | 44 ++++++++++++++++++++++++- backend/scripts/transform.ts | 36 +++++++++++++++++++- backend/src/schema.ts | 4 ++- backend/src/server.test.ts | 1 + frontend/src/mocks/recipes.ts | 3 ++ frontend/src/pages/RecipeDetailPage.tsx | 1 + frontend/src/types/recipe.ts | 1 + 10 files changed, 97 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7dc48ff..c0f70e5 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,10 @@ Catalog is built from two Kaggle datasets, both scraped from epicurious.com - Matches photos to Epicurious recipes by title, verified by ingredient overlap, since the two datasets aren't otherwise linked. - Converts matched photos to `.webp` in `data/images/`. +- Estimates carbohydrates per serving, which the Epicurious data lacks, from + calories, protein and fat: `(kcal - 4·protein - 9·fat) / 4` (Atwater + factors). It ignores fibre and alcohol, so the UI labels it "Carbs (est.)"; + recipes with missing or contradictory macros get no estimate. - Loads everything into Postgres (`backend/db/schema.sql` + inserts), with full-text search over title and ingredients. diff --git a/backend/db/schema.sql b/backend/db/schema.sql index ac1b773..39e99cd 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -16,6 +16,7 @@ CREATE TABLE recipes ( protein integer, fat integer, sodium integer, + carbs integer, -- estimated from calories/protein/fat, see scripts/transform.ts image text, search tsvector NOT NULL ); @@ -25,4 +26,5 @@ CREATE INDEX recipes_categories_idx ON recipes USING gin (categories); CREATE INDEX recipes_rating_idx ON recipes (rating); CREATE INDEX recipes_calories_idx ON recipes (calories); CREATE INDEX recipes_protein_idx ON recipes (protein); +CREATE INDEX recipes_carbs_idx ON recipes (carbs); CREATE INDEX recipes_title_idx ON recipes (title); diff --git a/backend/scripts/import.ts b/backend/scripts/import.ts index 0b23e1b..2350787 100644 --- a/backend/scripts/import.ts +++ b/backend/scripts/import.ts @@ -183,6 +183,7 @@ try { 'integer', 'integer', 'integer', + 'integer', 'text', ] const COLS_PER_ROW = COLUMN_TYPES.length @@ -204,18 +205,19 @@ try { r.protein, r.fat, r.sodium, + r.carbs, imagePathByRecipeId.get(r.id) ?? null, ) // search is derived from title/ingredients, computed here instead of // a second UPDATE pass over the whole table. return ( - `(${p(1)},${p(2)},${p(3)},${p(4)},${p(5)},${p(6)},${p(7)},${p(8)},${p(9)},${p(10)},${p(11)},${p(12)}, ` + + `(${p(1)},${p(2)},${p(3)},${p(4)},${p(5)},${p(6)},${p(7)},${p(8)},${p(9)},${p(10)},${p(11)},${p(12)},${p(13)}, ` + `setweight(to_tsvector('english', ${p(2)}), 'A') || ` + `setweight(to_tsvector('english', array_to_string(${p(4)}, ' ')), 'B'))` ) }) await client.query( - `INSERT INTO recipes (id, title, description, ingredients, directions, categories, rating, calories, protein, fat, sodium, image, search) VALUES ${rowsSql.join(',')}`, + `INSERT INTO recipes (id, title, description, ingredients, directions, categories, rating, calories, protein, fat, sodium, carbs, image, search) VALUES ${rowsSql.join(',')}`, values, ) } diff --git a/backend/scripts/transform.test.ts b/backend/scripts/transform.test.ts index 4cfcf93..20ffafb 100644 --- a/backend/scripts/transform.test.ts +++ b/backend/scripts/transform.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'vitest' -import { buildCatalog, ingredientOverlap, normalizeTitle } from './transform.ts' +import { + buildCatalog, + estimateCarbs, + ingredientOverlap, + normalizeTitle, +} from './transform.ts' import type { EpiRaw, ImageRaw } from './transform.ts' // Minimal valid Epicurious row, overridable per test so each test only @@ -50,7 +55,44 @@ describe('ingredientOverlap', () => { }) }) +describe('estimateCarbs', () => { + test('attributes the calories not explained by protein and fat to carbs', () => { + // 919 - 4*87 - 9*31 = 292 kcal left -> 73 g + expect(estimateCarbs(919, 87, 31)).toBe(73) + }) + + test('is null when any input is missing', () => { + expect(estimateCarbs(null, 10, 10)).toBeNull() + expect(estimateCarbs(500, null, 10)).toBeNull() + expect(estimateCarbs(500, 10, null)).toBeNull() + }) + + test('clamps small negative rounding noise to 0', () => { + // 100 - 4*5 - 9*9 = -1 kcal + expect(estimateCarbs(100, 5, 9)).toBe(0) + }) + + test('is null when the macros clearly contradict the calories', () => { + // 100 - 4*10 - 9*20 = -120 kcal -> -30 g + expect(estimateCarbs(100, 10, 20)).toBeNull() + }) +}) + describe('buildCatalog', () => { + test('estimates carbs from the sanitised nutrient values', () => { + const { recipes } = buildCatalog( + [ + epi({ title: 'Good', calories: 540, protein: 18, fat: 14 }), + epi({ title: 'Bogus', calories: 30_000_000, protein: 18, fat: 14 }), + ], + [], + ) + const byTitle = Object.fromEntries(recipes.map((r) => [r.title, r])) + expect(byTitle.Good.carbs).toBe(86) + // calories above the sanity cap become null, so no estimate either + expect(byTitle.Bogus.carbs).toBeNull() + }) + test('drops null, titleless and ingredient-less rows', () => { const { recipes, report } = buildCatalog( [ diff --git a/backend/scripts/transform.ts b/backend/scripts/transform.ts index e1e2692..ef461f2 100644 --- a/backend/scripts/transform.ts +++ b/backend/scripts/transform.ts @@ -15,6 +15,17 @@ const MAX_SODIUM = 20000 // mg // title match enough to attach the photo. const MIN_INGREDIENT_OVERLAP = 0.5 +// Epicurious has no carbohydrate column, so carbs are estimated from the +// other macros with the Atwater factors (4 kcal/g protein and carbs, 9 kcal/g +// fat). Fibre and alcohol are ignored, which is why the UI labels it "est.". +const KCAL_PER_G_PROTEIN = 4 +const KCAL_PER_G_CARBS = 4 +const KCAL_PER_G_FAT = 9 +// A slightly negative result is rounding noise in the source values (they are +// whole numbers); anything below this means calories and macros contradict +// each other, so the estimate is "unknown" rather than 0. +const CARB_TOLERANCE_G = 5 + export type EpiRaw = { title?: string | null desc?: string | null @@ -43,6 +54,7 @@ export type Recipe = { protein: number | null fat: number | null sodium: number | null + carbs: number | null // estimated grams, see estimateCarbs imageName: string | null // Image_Name of the matched photo (jpg basename without extension), or null } @@ -108,6 +120,19 @@ function sanitizeNutrient( return Math.round(value) } +export function estimateCarbs( + calories: number | null, + protein: number | null, + fat: number | null, +): number | null { + if (calories === null || protein === null || fat === null) return null + const carbs = + (calories - KCAL_PER_G_PROTEIN * protein - KCAL_PER_G_FAT * fat) / + KCAL_PER_G_CARBS + if (carbs < -CARB_TOLERANCE_G) return null + return Math.max(0, Math.round(carbs)) +} + function dedupeCategories(categories: string[] | null | undefined): string[] { if (!categories) return [] const seen = new Set() @@ -122,7 +147,9 @@ function dedupeCategories(categories: string[] | null | undefined): string[] { } // A validated Epicurious row before dedupe/matching/id assignment. -type Candidate = Omit & { normalizedTitle: string } +type Candidate = Omit & { + normalizedTitle: string +} // Dedupe rule: among rows that normalise to the same title, keep the one // with a real rating over one without, then the one with calories over one @@ -268,6 +295,13 @@ export function buildCatalog( protein: candidate.protein, fat: candidate.fat, sodium: candidate.sodium, + // Derived from the already sanitised values, so the bogus outliers + // capped above never feed into it. + carbs: estimateCarbs( + candidate.calories, + candidate.protein, + candidate.fat, + ), imageName, } }) diff --git a/backend/src/schema.ts b/backend/src/schema.ts index 6703987..c6dfb63 100644 --- a/backend/src/schema.ts +++ b/backend/src/schema.ts @@ -17,6 +17,8 @@ export const typeDefs = `#graphql fat: Int "milligrams" sodium: Int + "grams, estimated from calories, protein and fat (the dataset has no carb values)" + carbs: Int "Path relative to the site root, e.g. images/42.webp, null if no photo" image: String } @@ -83,7 +85,7 @@ const MAX_LIMIT = 50 // Everything except the internal `search` tsvector const COLUMNS = - 'id, title, description, ingredients, directions, categories, rating, calories, protein, fat, sodium, image' + 'id, title, description, ingredients, directions, categories, rating, calories, protein, fat, sodium, carbs, image' // Sort comes from the client, so only these fixed clauses ever reach the SQL. // The search term is always $1 when present, which RELEVANCE relies on. diff --git a/backend/src/server.test.ts b/backend/src/server.test.ts index a453c8d..5afd6a1 100644 --- a/backend/src/server.test.ts +++ b/backend/src/server.test.ts @@ -16,6 +16,7 @@ const recipe = { protein: 87, fat: 31, sodium: 4704, + carbs: 73, image: null, } diff --git a/frontend/src/mocks/recipes.ts b/frontend/src/mocks/recipes.ts index 3c9e8e5..ee7150c 100644 --- a/frontend/src/mocks/recipes.ts +++ b/frontend/src/mocks/recipes.ts @@ -25,6 +25,7 @@ export const mockRecipes: Recipe[] = [ protein: 87, fat: 31, sodium: 4704, + carbs: 73, image: null, }, { @@ -43,6 +44,7 @@ export const mockRecipes: Recipe[] = [ protein: 52, fat: 28, sodium: 1200, + carbs: 65, image: null, }, { @@ -61,6 +63,7 @@ export const mockRecipes: Recipe[] = [ protein: 18, fat: 14, sodium: 680, + carbs: 86, image: null, }, ] diff --git a/frontend/src/pages/RecipeDetailPage.tsx b/frontend/src/pages/RecipeDetailPage.tsx index 49808a4..e6c07fd 100644 --- a/frontend/src/pages/RecipeDetailPage.tsx +++ b/frontend/src/pages/RecipeDetailPage.tsx @@ -15,6 +15,7 @@ function nutrientsOf(recipe: Recipe): Nutrient[] { { label: 'Calories', value: recipe.calories, unit: 'kcal' }, { label: 'Protein', value: recipe.protein, unit: 'g' }, { label: 'Fat', value: recipe.fat, unit: 'g' }, + { label: 'Carbs (est.)', value: recipe.carbs, unit: 'g' }, { label: 'Sodium', value: recipe.sodium, unit: 'mg' }, ] } diff --git a/frontend/src/types/recipe.ts b/frontend/src/types/recipe.ts index 7b98bbb..ee2f3e5 100644 --- a/frontend/src/types/recipe.ts +++ b/frontend/src/types/recipe.ts @@ -11,5 +11,6 @@ export type Recipe = { protein: number | null fat: number | null sodium: number | null + carbs: number | null // estimated by the backend import image: string | null }