///|
pub(all) struct MealPrepOption {
recipe_title : String
servings : Int
verdict : String
plan_score : Int
rotation_score : Int
expiring_used_count : Int
expiring_unused_count : Int
shopping_cost_cents : Int
total_protein_g : Int
score : Int
reason : String
} derive(Eq, Debug)
///|
pub fn MealPrepOption::MealPrepOption(
recipe_title? : String = "",
servings? : Int = 0,
verdict? : String = "",
plan_score? : Int = 0,
rotation_score? : Int = 0,
expiring_used_count? : Int = 0,
expiring_unused_count? : Int = 0,
shopping_cost_cents? : Int = 0,
total_protein_g? : Int = 0,
score? : Int = 0,
reason? : String = "",
) -> MealPrepOption {
{
recipe_title,
servings,
verdict,
plan_score,
rotation_score,
expiring_used_count,
expiring_unused_count,
shopping_cost_cents,
total_protein_g,
score,
reason,
}
}
///|
pub(all) struct MealPrepPlan {
days : Int
window_days : Int
options : Array[MealPrepOption]
best : MealPrepOption
verdict : String
score : Int
} derive(Eq, Debug)
///|
pub fn MealPrepPlan::MealPrepPlan(
days? : Int = 0,
window_days? : Int = 0,
options? : Array[MealPrepOption] = [],
best? : MealPrepOption = MealPrepOption(),
verdict? : String = "",
score? : Int = 0,
) -> MealPrepPlan {
{ days, window_days, options, best, verdict, score }
}
///|
fn recipe_uses_pantry_item(recipe : Recipe, item : PantryItem) -> Bool {
for ingredient in recipe.ingredients {
if same_name(ingredient.name, item.name) {
return true
}
}
false
}
///|
fn expiring_used_by_recipe(
recipe : Recipe,
pantry : Array[PantryItem],
window_days : Int,
) -> Int {
let mut count = 0
for item in pantry {
if item.expires_in_days <= window_days &&
recipe_uses_pantry_item(recipe, item) {
count += 1
}
}
count
}
///|
fn meal_prep_reason(option : MealPrepOption) -> String {
if option.verdict == "blocked-allergen" {
return "blocked because allergens remain after substitutions"
}
if option.expiring_used_count > 0 && option.shopping_cost_cents == 0 {
return "uses expiring pantry stock without extra shopping"
}
if option.expiring_used_count > 0 {
return "uses expiring pantry stock and keeps shopping gaps explicit"
}
if option.shopping_cost_cents == 0 {
return "ready from pantry stock"
}
"valid meal option with shopping gaps"
}
///|
fn clamp_meal_prep_score(score : Int) -> Int {
if score < 0 {
0
} else if score > 150 {
150
} else {
score
}
}
///|
pub fn meal_prep_option(
recipe : Recipe,
servings : Int,
profile : DietProfile,
pantry : Array[PantryItem],
substitutions : Array[Substitution],
window_days : Int,
) -> MealPrepOption {
let scaled = scale_recipe(recipe, servings)
let adapted = adapt_recipe(scaled, profile, substitutions)
let plan = plan_recipe(recipe, servings, profile, pantry, substitutions)
let rotation = rotation_plan(adapted, pantry)
let used = expiring_used_by_recipe(adapted, pantry, window_days)
let unused = expiring_not_in_recipe(adapted, pantry, window_days).length()
let base = plan_score(plan)
let waste_bonus = used * 14
let rotation_bonus = rotation_score(rotation) / 12
let unused_penalty = unused * 8
let shopping_penalty = shopping_cost(plan.shopping) / 600
let allergen_penalty = plan.allergen_hit_count * 40
let score = clamp_meal_prep_score(
base +
waste_bonus +
rotation_bonus -
unused_penalty -
shopping_penalty -
allergen_penalty,
)
let draft = MealPrepOption(
recipe_title=plan.recipe_title,
servings=plan.servings,
verdict=plan.verdict,
plan_score=base,
rotation_score=rotation_score(rotation),
expiring_used_count=used,
expiring_unused_count=unused,
shopping_cost_cents=shopping_cost(plan.shopping),
total_protein_g=plan.total_protein_g,
score~,
)
MealPrepOption(
recipe_title=draft.recipe_title,
servings=draft.servings,
verdict=draft.verdict,
plan_score=draft.plan_score,
rotation_score=draft.rotation_score,
expiring_used_count=draft.expiring_used_count,
expiring_unused_count=draft.expiring_unused_count,
shopping_cost_cents=draft.shopping_cost_cents,
total_protein_g=draft.total_protein_g,
score=draft.score,
reason=meal_prep_reason(draft),
)
}
///|
pub fn meal_prep_rankings(
recipes : Array[Recipe],
servings : Int,
profile : DietProfile,
pantry : Array[PantryItem],
substitutions : Array[Substitution],
window_days : Int,
) -> Array[MealPrepOption] {
let options = Array::new(capacity=recipes.length())
for recipe in recipes {
options.push(
meal_prep_option(
recipe, servings, profile, pantry, substitutions, window_days,
),
)
}
options
}
///|
pub fn best_meal_prep_option(options : Array[MealPrepOption]) -> MealPrepOption {
if options.length() == 0 {
return MealPrepOption(verdict="empty")
}
let mut best = options[0]
for option in options {
if option.score > best.score {
best = option
} else if option.score == best.score &&
option.expiring_used_count > best.expiring_used_count {
best = option
} else if option.score == best.score &&
option.expiring_used_count == best.expiring_used_count &&
option.shopping_cost_cents < best.shopping_cost_cents {
best = option
}
}
best
}
///|
pub fn zero_waste_meal_prep(
recipes : Array[Recipe],
servings : Int,
profile : DietProfile,
pantry : Array[PantryItem],
substitutions : Array[Substitution],
days : Int,
window_days : Int,
) -> MealPrepPlan {
let options = meal_prep_rankings(
recipes, servings, profile, pantry, substitutions, window_days,
)
let best = best_meal_prep_option(options)
let verdict = if options.length() == 0 {
"empty"
} else if best.verdict == "blocked-allergen" {
"needs-safe-recipe"
} else if best.expiring_used_count > 0 {
"zero-waste-priority"
} else {
"pantry-balanced"
}
MealPrepPlan(days~, window_days~, options~, best~, verdict~, score=best.score)
}
///|
pub fn MealPrepPlan::summary(self : MealPrepPlan) -> String {
"mealprep=" +
self.verdict +
" days=" +
self.days.to_string() +
" window=" +
self.window_days.to_string() +
" options=" +
self.options.length().to_string() +
" best=\"" +
self.best.recipe_title +
"\" score=" +
self.score.to_string() +
" expiring-used=" +
self.best.expiring_used_count.to_string()
}
///|
pub fn MealPrepPlan::to_markdown(self : MealPrepPlan) -> String {
let out = StringBuilder()
out.write_string("# PantryCalc Zero-Waste Meal Prep\n\n")
out.write_string("- Verdict: " + self.verdict + "\n")
out.write_string("- Days: " + self.days.to_string() + "\n")
out.write_string("- Expiry window: " + self.window_days.to_string() + "\n")
out.write_string("- Best recipe: " + self.best.recipe_title + "\n")
out.write_string("- Best score: " + self.score.to_string() + "\n\n")
out.write_string("## Ranked Options\n\n")
if self.options.length() == 0 {
out.write_string("- No recipes provided.\n")
}
for option in self.options {
out.write_string(
"- " +
option.recipe_title +
": score " +
option.score.to_string() +
", verdict " +
option.verdict +
", expiring used " +
option.expiring_used_count.to_string() +
", shopping " +
money_text(option.shopping_cost_cents) +
", " +
option.reason +
"\n",
)
}
out.to_string()
}
///|
pub fn example_zero_waste_meal_prep() -> MealPrepPlan {
zero_waste_meal_prep(
[example_recipe(), pantry_rice_bowl()],
4,
example_profile(),
example_pantry(),
default_substitutions(),
3,
2,
)
}