Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions backend/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand All @@ -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);
6 changes: 4 additions & 2 deletions backend/scripts/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ try {
'integer',
'integer',
'integer',
'integer',
'text',
]
const COLS_PER_ROW = COLUMN_TYPES.length
Expand All @@ -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,
)
}
Expand Down
44 changes: 43 additions & 1 deletion backend/scripts/transform.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
[
Expand Down
36 changes: 35 additions & 1 deletion backend/scripts/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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<string>()
Expand All @@ -122,7 +147,9 @@ function dedupeCategories(categories: string[] | null | undefined): string[] {
}

// A validated Epicurious row before dedupe/matching/id assignment.
type Candidate = Omit<Recipe, 'id' | 'imageName'> & { normalizedTitle: string }
type Candidate = Omit<Recipe, 'id' | 'imageName' | 'carbs'> & {
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
Expand Down Expand Up @@ -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,
}
})
Expand Down
4 changes: 3 additions & 1 deletion backend/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions backend/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const recipe = {
protein: 87,
fat: 31,
sodium: 4704,
carbs: 73,
image: null,
}

Expand Down
3 changes: 3 additions & 0 deletions frontend/src/mocks/recipes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const mockRecipes: Recipe[] = [
protein: 87,
fat: 31,
sodium: 4704,
carbs: 73,
image: null,
},
{
Expand All @@ -43,6 +44,7 @@ export const mockRecipes: Recipe[] = [
protein: 52,
fat: 28,
sodium: 1200,
carbs: 65,
image: null,
},
{
Expand All @@ -61,6 +63,7 @@ export const mockRecipes: Recipe[] = [
protein: 18,
fat: 14,
sodium: 680,
carbs: 86,
image: null,
},
]
1 change: 1 addition & 0 deletions frontend/src/pages/RecipeDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
]
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}