Skip to content
Open
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
655 changes: 655 additions & 0 deletions docs/superpowers/plans/2026-06-05-nutrition-display.md

Large diffs are not rendered by default.

129 changes: 129 additions & 0 deletions docs/superpowers/specs/2026-06-05-nutrition-display-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Nutrition Info Display — Design Spec

**Date:** 2026-06-05
**Status:** Approved

## Goal

Display nutrition information stored in `.cook` recipe metadata in the web UI, when available. Two storage formats exist: flat top-level keys (hand-authored) and a nested `nutrition:` mapping (from imported recipes).

## Data formats supported

**Flat keys** (hand-authored):
```
>> calories: 350
>> protein: 25g
>> fat: 12g
>> carbohydrates: 40g
>> fiber: 5g
>> sugar: 8g
>> sodium: 480mg
```

**Nested mapping** (importer output):
```
>> nutrition:
>> calories: 350 calories
>> fat: 18 grams fat
>> protein: 25g
>> carbohydrates: 40g
>> fiber: 5g
>> sugar: 8g
>> sodium: 480mg
>> serving size: 1 cup
```

## Architecture

### 1. `src/server/templates.rs` — new `NutritionData` struct

Add a new struct and a field to `RecipeMetadata`:

```rust
pub struct NutritionData {
pub calories: Option<String>,
pub protein: Option<String>,
pub fat: Option<String>,
pub saturated_fat: Option<String>,
pub carbohydrates: Option<String>,
pub fiber: Option<String>,
pub sugar: Option<String>,
pub sodium: Option<String>,
pub serving_size: Option<String>,
}

// RecipeMetadata gains:
pub nutrition: Option<NutritionData>,
```

### 2. `src/server/builders.rs` — extract nutrition

Before building `custom_metadata`, extract nutrition from both formats:

1. Check if `nutrition` key exists as a YAML mapping → read sub-keys
2. Otherwise look for flat top-level keys (`calories`, `protein`, `fat`, `saturated fat` / `saturated_fat`, `carbohydrates`, `fiber`, `sugar`, `sodium`, `serving size` / `serving_size`)
3. A `NutritionData` is `None` if no fields are found
4. Exclude all nutrition-related keys from the `custom` vec to avoid duplication

Known flat key names to exclude from custom:
`calories`, `protein`, `fat`, `saturated fat`, `saturated_fat`, `carbohydrates`, `fiber`, `sugar`, `sodium`, `serving size`, `serving_size`

The same extraction logic also applies to the second `RecipeMetadata` builder at line ~937 (menu template).

### 3. `templates/recipe.html` — collapsible section

Position: between the metadata pills container (`#metadata-container`) and the ingredients/steps grid.

Render only when `nutrition` is `Some`. Use a native HTML `<details>`/`<summary>` element (no JS required). Style with Tailwind to match the existing card aesthetic.

Visual design:
- Summary line: `🔬 Nutrition` (label via `tr.t("meta-nutrition")`) with a chevron indicator
- Expanded content: a row of pill-like stat boxes for each present field
- Primary fields (larger): Calories, Protein, Fat, Carbs
- Secondary fields (smaller): Fiber, Sugar, Sodium, Saturated Fat, Serving size
- Label/value layout inside each pill; all labels use `tr.t("meta-<field>")` keys

### 4. `locales/*/recipes.ftl` — translation keys

Add the following keys to all 7 locale files (en-US, de-DE, es-ES, eu-ES, fr-FR, nl-NL, sv-SE):

```
meta-nutrition = Nutrition
meta-calories = Calories
meta-protein = Protein
meta-fat = Fat
meta-saturated-fat = Saturated Fat
meta-carbohydrates = Carbohydrates
meta-fiber = Fiber
meta-sugar = Sugar
meta-sodium = Sodium
meta-serving-size = Serving Size
```

Translations per locale (best-effort; native speakers should review):

| Key | fr-FR | de-DE | es-ES | eu-ES | nl-NL | sv-SE |
|-----|-------|-------|-------|-------|-------|-------|
| meta-nutrition | Valeurs nutritionnelles | Nährwerte | Información nutricional | Elikadura balioak | Voedingswaarden | Näringsvärden |
| meta-calories | Calories | Kalorien | Calorías | Kaloriak | Calorieën | Kalorier |
| meta-protein | Protéines | Eiweiß | Proteínas | Proteinak | Eiwitten | Protein |
| meta-fat | Lipides | Fett | Grasas | Koipeak | Vetten | Fett |
| meta-saturated-fat | Acides gras saturés | Gesättigte Fettsäuren | Grasas saturadas | Gantz aseak | Verzadigde vetten | Mättat fett |
| meta-carbohydrates | Glucides | Kohlenhydrate | Carbohidratos | Karbohidratoak | Koolhydraten | Kolhydrater |
| meta-fiber | Fibres | Ballaststoffe | Fibra | Zuntzak | Vezels | Fibrer |
| meta-sugar | Sucres | Zucker | Azúcares | Azukreak | Suikers | Socker |
| meta-sodium | Sodium | Natrium | Sodio | Sodioa | Natrium | Natrium |
| meta-serving-size | Par portion | Portionsgröße | Tamaño de porción | Zerbitzatu tamaina | Portiegrootte | Portionsstorlek |

## Scope

- No changes to `cooklang-rs` — nutrition is not a `StdKey`; we read it from the raw metadata map
- No changes to the CLI `recipe` command output — this is UI-only
- No per-serving scaling of nutrition values — values are displayed as stored
- Both builder instances in `builders.rs` updated for consistency

## Out of scope

- Nutrition per-serving math when scaling
- Editing nutrition from the UI
- CLI text/JSON output of nutrition
10 changes: 10 additions & 0 deletions locales/de-DE/recipes.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ meta-total-time = Gesamtzeit
meta-servings = Portionen
meta-difficulty = Schwierigkeit
meta-description = Beschreibung
meta-nutrition = Nährwerte
meta-calories = Kalorien
meta-protein = Eiweiß
meta-fat = Fett
meta-saturated-fat = Gesättigte Fettsäuren
meta-carbohydrates = Kohlenhydrate
meta-fiber = Ballaststoffe
meta-sugar = Zucker
meta-sodium = Natrium
meta-serving-size = Portionsgröße

# Recipe Types
recipe-type-menu = Menü
Expand Down
10 changes: 10 additions & 0 deletions locales/en-US/recipes.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ meta-total-time = Total Time
meta-servings = Servings
meta-difficulty = Difficulty
meta-description = Description
meta-nutrition = Nutrition
meta-calories = Calories
meta-protein = Protein
meta-fat = Fat
meta-saturated-fat = Saturated Fat
meta-carbohydrates = Carbohydrates
meta-fiber = Fiber
meta-sugar = Sugar
meta-sodium = Sodium
meta-serving-size = Serving Size

# Recipe Types
recipe-type-menu = Menu
Expand Down
10 changes: 10 additions & 0 deletions locales/es-ES/recipes.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ meta-total-time = Tiempo Total
meta-servings = Porciones
meta-difficulty = Dificultad
meta-description = Descripción
meta-nutrition = Información nutricional
meta-calories = Calorías
meta-protein = Proteínas
meta-fat = Grasas
meta-saturated-fat = Grasas saturadas
meta-carbohydrates = Carbohidratos
meta-fiber = Fibra
meta-sugar = Azúcares
meta-sodium = Sodio
meta-serving-size = Tamaño de porción

# Recipe Types
recipe-type-menu = Menú
Expand Down
10 changes: 10 additions & 0 deletions locales/eu-ES/recipes.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ meta-total-time = Denbora guztira
meta-servings = Anoak
meta-difficulty = Zailtasuna
meta-description = Deskribapena
meta-nutrition = Elikadura balioak
meta-calories = Kaloriak
meta-protein = Proteinak
meta-fat = Koipeak
meta-saturated-fat = Gantz aseak
meta-carbohydrates = Karbohidratoak
meta-fiber = Zuntzak
meta-sugar = Azukreak
meta-sodium = Sodioa
meta-serving-size = Zerbitzatu tamaina

# Recipe Types
recipe-type-menu = Menua
Expand Down
10 changes: 10 additions & 0 deletions locales/fr-FR/recipes.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ meta-total-time = Temps Total
meta-servings = Portions
meta-difficulty = Difficulté
meta-description = Description
meta-nutrition = Valeurs nutritionnelles
meta-calories = Calories
meta-protein = Protéines
meta-fat = Lipides
meta-saturated-fat = Acides gras saturés
meta-carbohydrates = Glucides
meta-fiber = Fibres
meta-sugar = Sucres
meta-sodium = Sodium
meta-serving-size = Par portion

# Recipe Types
recipe-type-menu = Menu
Expand Down
10 changes: 10 additions & 0 deletions locales/nl-NL/recipes.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ meta-total-time = Totale tijd
meta-servings = Porties
meta-difficulty = Moeilijkheidsgraad
meta-description = Beschrijving
meta-nutrition = Voedingswaarden
meta-calories = Calorieën
meta-protein = Eiwitten
meta-fat = Vetten
meta-saturated-fat = Verzadigde vetten
meta-carbohydrates = Koolhydraten
meta-fiber = Vezels
meta-sugar = Suikers
meta-sodium = Natrium
meta-serving-size = Portiegrootte

# Recipe Types
recipe-type-menu = Menu
Expand Down
10 changes: 10 additions & 0 deletions locales/sv-SE/recipes.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ meta-total-time = Total tid
meta-servings = Portioner
meta-difficulty = Svårighet
meta-description = Beskrivning
meta-nutrition = Näringsvärden
meta-calories = Kalorier
meta-protein = Protein
meta-fat = Fett
meta-saturated-fat = Mättat fett
meta-carbohydrates = Kolhydrater
meta-fiber = Fibrer
meta-sugar = Socker
meta-sodium = Natrium
meta-serving-size = Portionsstorlek

# Recipe Types
recipe-type-menu = Meny
Expand Down
112 changes: 111 additions & 1 deletion src/server/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -692,10 +692,15 @@ pub fn build_recipe_template(input: RecipeBuildInput<'_>) -> Result<RecipeBuildO
})
};

let nutrition = extract_nutrition(&recipe.metadata);

let mut custom_metadata = Vec::new();
for (key, value) in recipe.metadata.map_filtered() {
if let (Some(key_str), Some(val_str)) = (key.as_str(), value.as_str()) {
if key_str.starts_with("source.") || key_str.starts_with("time.") {
if key_str.starts_with("source.")
|| key_str.starts_with("time.")
|| is_nutrition_key(key_str)
{
continue;
}

Expand Down Expand Up @@ -723,6 +728,7 @@ pub fn build_recipe_template(input: RecipeBuildInput<'_>) -> Result<RecipeBuildO
source: get_field("source").or_else(|| get_field("source.name")),
source_url: get_field("source.url"),
custom: custom_metadata,
nutrition,
})
};

Expand Down Expand Up @@ -949,9 +955,14 @@ fn build_menu_template_inner(
})
};

let nutrition = extract_nutrition(&recipe.metadata);

let mut custom_metadata = Vec::new();
for (key, value) in recipe.metadata.map_filtered() {
if let (Some(key_str), Some(val_str)) = (key.as_str(), value.as_str()) {
if is_nutrition_key(key_str) {
continue;
}
custom_metadata.push((key_str.to_string(), val_str.to_string()));
}
}
Expand All @@ -974,6 +985,7 @@ fn build_menu_template_inner(
source: get_field("source").or_else(|| get_field("source.name")),
source_url: get_field("source.url"),
custom: custom_metadata,
nutrition,
})
};

Expand Down Expand Up @@ -1042,3 +1054,101 @@ fn get_image_path(base_path: &Utf8Path, prefix: &str, img_path: String) -> Optio
}
}
}

const NUTRITION_KEYS: &[&str] = &[
"nutrition",
"calories",
"protein",
"fat",
"saturated fat",
"saturated_fat",
"carbohydrates",
"fiber",
"sugar",
"sodium",
"serving size",
"serving_size",
];

fn is_nutrition_key(key: &str) -> bool {
NUTRITION_KEYS.contains(&key)
}

fn yaml_value_to_string(v: &serde_yaml::Value) -> Option<String> {
if let Some(s) = v.as_str() {
Some(s.to_string())
} else if let Some(n) = v.as_i64() {
Some(n.to_string())
} else {
v.as_f64().map(crate::util::format::format_number)
}
}

fn extract_nutrition(metadata: &cooklang::Metadata) -> Option<NutritionData> {
// Try nested `nutrition:` mapping first (importer format)
if let Some(nutrition_val) = metadata.map.get("nutrition") {
if let Some(nutrition_map) = nutrition_val.as_mapping() {
let get = |key: &str| -> Option<String> {
nutrition_map.get(key).and_then(yaml_value_to_string)
};

let data = NutritionData {
calories: get("calories"),
protein: get("protein"),
fat: get("fat"),
saturated_fat: get("saturated fat").or_else(|| get("saturated_fat")),
carbohydrates: get("carbohydrates"),
fiber: get("fiber"),
sugar: get("sugar"),
sodium: get("sodium"),
serving_size: get("serving size").or_else(|| get("serving_size")),
};

let has_data = data.calories.is_some()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block of code (computing has_data) is duplicated below. If it was me writing this, I'd instead add something like the following, perhaps in templates.rs

impl NutritionData {
    pub fn has_data(&self) -> bool {
        self.calories.is_some()
            || self.protein.is_some()
            || self.fat.is_some()
            || self.saturated_fat.is_some()
            || self.carbohydrates.is_some()
            || self.fiber.is_some()
            || self.sugar.is_some()
            || self.sodium.is_some()
            || self.serving_size.is_some()
    }
}

and call that twice.

|| data.protein.is_some()
|| data.fat.is_some()
|| data.saturated_fat.is_some()
|| data.carbohydrates.is_some()
|| data.fiber.is_some()
|| data.sugar.is_some()
|| data.sodium.is_some()
|| data.serving_size.is_some();

if has_data {
return Some(data);
}
}
}

// Fall back to flat top-level keys (hand-authored format)
let get =
|key: &str| -> Option<String> { metadata.map.get(key).and_then(yaml_value_to_string) };

let data = NutritionData {
calories: get("calories"),
protein: get("protein"),
fat: get("fat"),
saturated_fat: get("saturated fat").or_else(|| get("saturated_fat")),
carbohydrates: get("carbohydrates"),
fiber: get("fiber"),
sugar: get("sugar"),
sodium: get("sodium"),
serving_size: get("serving size").or_else(|| get("serving_size")),
};

let has_data = data.calories.is_some()
|| data.protein.is_some()
|| data.fat.is_some()
|| data.saturated_fat.is_some()
|| data.carbohydrates.is_some()
|| data.fiber.is_some()
|| data.sugar.is_some()
|| data.sodium.is_some()
|| data.serving_size.is_some();

if has_data {
Some(data)
} else {
None
}
}
Loading
Loading