///|
pub fn ingredient_has_allergen(
ingredient : Ingredient,
profile : DietProfile,
) -> Bool {
count_intersection(ingredient.allergens, profile.avoid_allergens) > 0
}
///|
pub fn allergen_hit_count(recipe : Recipe, profile : DietProfile) -> Int {
let mut count = 0
for ingredient in recipe.ingredients {
if ingredient_has_allergen(ingredient, profile) {
count += 1
}
}
count
}
///|
pub fn disliked_tag_count(recipe : Recipe, profile : DietProfile) -> Int {
let mut count = 0
for ingredient in recipe.ingredients {
if count_intersection(ingredient.tags, profile.dislike_tags) > 0 {
count += 1
}
}
count
}
///|
fn pantry_lookup(pantry : Array[PantryItem], name : String) -> PantryItem? {
for item in pantry {
if same_name(item.name, name) {
return Some(item)
}
}
None
}
///|
fn substitution_lookup(
substitutions : Array[Substitution],
ingredient : Ingredient,
profile : DietProfile,
) -> Substitution? {
for sub in substitutions {
if same_name(sub.source, ingredient.name) {
let removes = count_intersection(
ingredient.allergens,
profile.avoid_allergens,
)
let target_hits = count_intersection(
sub.target.allergens,
profile.avoid_allergens,
)
if removes > 0 && target_hits == 0 {
return Some(sub)
}
}
}
None
}
///|
fn apply_substitution(
ingredient : Ingredient,
sub : Substitution,
) -> Ingredient {
let q = Quantity(
amount=ingredient.quantity.amount * sub.ratio_percent / 100,
unit=ingredient.quantity.unit,
)
Ingredient(
name=sub.target.name,
group=sub.target.group,
quantity=q,
nutrition=sub.target.nutrition,
cost_cents=sub.target.cost_cents,
allergens=sub.target.allergens,
tags=sub.target.tags,
)
}
///|
pub fn adapt_recipe(
recipe : Recipe,
profile : DietProfile,
substitutions : Array[Substitution],
) -> Recipe {
let adapted = Array::new(capacity=recipe.ingredients.length())
for ingredient in recipe.ingredients {
match substitution_lookup(substitutions, ingredient, profile) {
Some(sub) => adapted.push(apply_substitution(ingredient, sub))
None => adapted.push(ingredient)
}
}
Recipe(
title=recipe.title,
servings=recipe.servings,
ingredients=adapted,
steps=recipe.steps,
notes=recipe.notes,
)
}
///|
pub fn used_substitutions(
recipe : Recipe,
profile : DietProfile,
substitutions : Array[Substitution],
) -> Array[Substitution] {
let used = Array::new()
for ingredient in recipe.ingredients {
match substitution_lookup(substitutions, ingredient, profile) {
Some(sub) => used.push(sub)
None => ()
}
}
used
}
///|
pub fn shopping_list(
recipe : Recipe,
pantry : Array[PantryItem],
) -> Array[ShoppingLine] {
let lines = Array::new(capacity=recipe.ingredients.length())
for ingredient in recipe.ingredients {
let available = match pantry_lookup(pantry, ingredient.name) {
Some(item) => item.quantity
None => Quantity(amount=0, unit=ingredient.quantity.unit)
}
let buy = quantity_missing(ingredient.quantity, available)
let cost = if ingredient.quantity.amount <= 0 {
0
} else {
ingredient.cost_cents * buy.amount / ingredient.quantity.amount
}
lines.push(
ShoppingLine(
name=ingredient.name,
needed=ingredient.quantity,
available~,
buy~,
estimated_cost_cents=cost,
),
)
}
lines
}
///|
pub fn missing_count(lines : Array[ShoppingLine]) -> Int {
let mut count = 0
for line in lines {
if line.buy.amount > 0 {
count += 1
}
}
count
}
///|
pub fn shopping_cost(lines : Array[ShoppingLine]) -> Int {
let mut total = 0
for line in lines {
total += line.estimated_cost_cents
}
total
}
///|
fn expiry_warnings(pantry : Array[PantryItem]) -> Array[PantryWarning] {
let warnings = Array::new()
for item in pantry {
if item.expires_in_days <= 0 {
warnings.push(
PantryWarning(
code="expired",
message=item.name + " has expired.",
severity="warn",
),
)
} else if item.expires_in_days <= 2 {
warnings.push(
PantryWarning(
code="use-soon",
message=item.name + " should be used soon.",
severity="info",
),
)
}
}
warnings
}
///|
fn diet_warnings(
recipe : Recipe,
profile : DietProfile,
) -> Array[PantryWarning] {
let warnings = Array::new()
let nutrition = total_nutrition(recipe)
let cost = total_cost(recipe)
if profile.max_total_kcal > 0 && nutrition.kcal > profile.max_total_kcal {
warnings.push(
PantryWarning(
code="kcal",
message="Recipe exceeds kcal target by " +
(nutrition.kcal - profile.max_total_kcal).to_string() +
".",
severity="warn",
),
)
}
if profile.min_total_protein_g > 0 &&
nutrition.protein_g < profile.min_total_protein_g {
warnings.push(
PantryWarning(
code="protein",
message="Recipe is below protein target by " +
(profile.min_total_protein_g - nutrition.protein_g).to_string() +
"g.",
severity="info",
),
)
}
if profile.max_total_cost_cents > 0 && cost > profile.max_total_cost_cents {
warnings.push(
PantryWarning(
code="cost",
message="Recipe exceeds cost target by " +
money_text(cost - profile.max_total_cost_cents) +
".",
severity="warn",
),
)
}
warnings
}
///|
fn concat_warnings(
left : Array[PantryWarning],
right : Array[PantryWarning],
) -> Array[PantryWarning] {
let out = Array::new(capacity=left.length() + right.length())
for item in left {
out.push(item)
}
for item in right {
out.push(item)
}
out
}
///|
pub fn plan_recipe(
recipe : Recipe,
servings : Int,
profile : DietProfile,
pantry : Array[PantryItem],
substitutions : Array[Substitution],
) -> PantryPlan {
let scaled = scale_recipe(recipe, servings)
let used = used_substitutions(scaled, profile, substitutions)
let adapted = adapt_recipe(scaled, profile, substitutions)
let shopping = shopping_list(adapted, pantry)
let nutrition = total_nutrition(adapted)
let allergen_hits = allergen_hit_count(adapted, profile)
let missing = missing_count(shopping)
let warnings = concat_warnings(
expiry_warnings(pantry),
diet_warnings(adapted, profile),
)
let verdict = if allergen_hits > 0 {
"blocked-allergen"
} else if missing > 0 {
"shopping-needed"
} else if warnings.length() > 0 {
"ready-with-notes"
} else {
"ready"
}
PantryPlan(
verdict~,
recipe_title=adapted.title,
servings~,
total_cost_cents=total_cost(adapted),
total_kcal=nutrition.kcal,
total_protein_g=nutrition.protein_g,
allergen_hit_count=allergen_hits,
missing_count=missing,
substitution_count=used.length(),
shopping~,
substitutions=used,
warnings~,
)
}
///|
pub fn plan_score(plan : PantryPlan) -> Int {
let mut score = 100
score -= plan.allergen_hit_count * 30
score -= plan.missing_count * 8
score -= plan.warnings.length() * 3
if score < 0 {
0
} else {
score
}
}