Restructure food suggestion data and builder logic

This commit is contained in:
2026-04-13 17:55:11 +02:00
parent 6c7c1f01c9
commit 305440a6b2
5 changed files with 820 additions and 67 deletions
+408 -66
View File
@@ -26,17 +26,30 @@ from .backup import RESTORE_CONFIRMATION_TEXT, export_backup_archive, restore_ba
from .constants import (
AVAILABILITY_LABELS,
BUILDER_LABELS,
BUILDER_OPTIONS,
DAYPART_SLUG_TO_MEAL_TYPE,
DEFAULT_CATEGORY_BUILDERS,
DAY_TEMPLATE_NAME_SUGGESTIONS,
DEFAULT_CATEGORIES,
ENERGY_DENSITY_LABELS,
ENERGY_DENSITY_OPTIONS,
FOOD_ROLE_DESCRIPTIONS,
FOOD_ROLE_LABELS,
FOOD_ROLE_OPTIONS,
ITEM_KIND_LABELS,
ITEM_KIND_SINGULAR_LABELS,
ITEM_SET_NAME_SUGGESTIONS,
MEAL_STYLE_LABELS,
MEAL_STYLE_OPTIONS,
MEAL_TYPE_LABELS,
MEAL_TYPE_OPTIONS,
NOTIFICATION_CHANNEL_OPTIONS,
PROTEIN_PREFERENCE_LABELS,
PROTEIN_PREFERENCE_OPTIONS,
SUGGESTION_STYLE_LABELS,
SUGGESTION_STYLE_OPTIONS,
SUGGESTION_PRIORITY_LABELS,
SUGGESTION_PRIORITY_OPTIONS,
VISIBILITY_DESCRIPTIONS,
VISIBILITY_LABELS,
WEEKDAY_OPTIONS,
@@ -196,6 +209,7 @@ def default_user_settings() -> dict:
"notification_channel": "in_app",
"suggestion_style": suggestion_style,
"energy_preference": suggestion_style_energy_preference(suggestion_style),
"protein_preference": "mixed",
"remind_before_shopping": True,
"remind_on_shopping_day": True,
"show_missing_for_upcoming_week": True,
@@ -261,6 +275,7 @@ def get_user_settings() -> dict:
settings["notification_channel"] = settings.get("notification_channel") or "in_app"
settings["suggestion_style"] = normalize_suggestion_style(settings.get("suggestion_style"), "balanced")
settings["energy_preference"] = suggestion_style_energy_preference(settings["suggestion_style"])
settings["protein_preference"] = normalize_protein_preference(settings.get("protein_preference"), "mixed")
return settings
@@ -283,8 +298,6 @@ def normalize_notification_channel(raw: str | None, default: str = "in_app") ->
def normalize_suggestion_style(raw: str | None, default: str = "balanced") -> str:
allowed = {value for value, _label in SUGGESTION_STYLE_OPTIONS}
if raw == "easy" or raw == "snack":
return "balanced"
return raw if raw in allowed else default
@@ -293,9 +306,57 @@ def normalize_energy_density(raw: str | None, default: str = "neutral") -> str:
return raw if raw in allowed else default
def normalize_base_type(raw: str | None, default: str = "neutral") -> str:
allowed = {value for value, _label in BUILDER_OPTIONS}
return raw if raw in allowed else default
def normalize_food_role(raw: str | None, default: str = "base") -> str:
allowed = {value for value, _label in FOOD_ROLE_OPTIONS}
return raw if raw in allowed else default
def normalize_suggestion_priority(raw: str | None, default: str = "normal") -> str:
allowed = {value for value, _label in SUGGESTION_PRIORITY_OPTIONS}
return raw if raw in allowed else default
def normalize_meal_type(raw: str | None, default: str = "snack") -> str:
allowed = {value for value, _label in MEAL_TYPE_OPTIONS}
return raw if raw in allowed else default
def normalize_meal_tags(values: list[str] | None) -> list[str]:
allowed = {value for value, _label in MEAL_STYLE_OPTIONS}
normalized: list[str] = []
for value in values or []:
if value in allowed and value not in normalized:
normalized.append(value)
return normalized
def encode_tag_list(values: list[str] | None) -> str:
return ",".join(normalize_meal_tags(values))
def decode_tag_list(raw: str | None) -> list[str]:
if not raw:
return []
return normalize_meal_tags([part.strip() for part in str(raw).split(",") if part.strip()])
def normalize_protein_preference(raw: str | None, default: str = "mixed") -> str:
allowed = {value for value, _label in PROTEIN_PREFERENCE_OPTIONS}
return raw if raw in allowed else default
def suggestion_style_energy_preference(style: str) -> str:
if style == "fitness":
return "low"
if style == "easy":
return "low"
if style == "snack":
return "neutral"
return "neutral"
@@ -365,6 +426,23 @@ def describe_record(entry: dict) -> dict:
entry["target_name"] = target_name
entry["energy_density"] = normalize_energy_density(entry.get("energy_density"), "neutral")
entry["energy_density_label"] = ENERGY_DENSITY_LABELS.get(entry["energy_density"], ENERGY_DENSITY_LABELS["neutral"])
entry["base_type"] = normalize_base_type(entry.get("base_type"), "neutral")
entry["base_type_label"] = BUILDER_LABELS.get(entry["base_type"], BUILDER_LABELS["neutral"])
entry["suggestion_role"] = normalize_food_role(entry.get("suggestion_role"), "base")
entry["suggestion_role_label"] = FOOD_ROLE_LABELS.get(entry["suggestion_role"], FOOD_ROLE_LABELS["base"])
entry["suggestion_priority"] = normalize_suggestion_priority(entry.get("suggestion_priority"), "normal")
entry["suggestion_priority_label"] = SUGGESTION_PRIORITY_LABELS.get(
entry["suggestion_priority"],
SUGGESTION_PRIORITY_LABELS["normal"],
)
entry["can_be_meal_core"] = bool(entry.get("can_be_meal_core"))
entry["meal_type"] = normalize_meal_type(
entry.get("meal_type"),
DAYPART_SLUG_TO_MEAL_TYPE.get(entry.get("daypart_slug"), "snack"),
)
entry["meal_type_label"] = MEAL_TYPE_LABELS.get(entry["meal_type"], MEAL_TYPE_LABELS["snack"])
entry["meal_tags"] = decode_tag_list(entry.get("meal_tags"))
entry["meal_tag_labels"] = [MEAL_STYLE_LABELS.get(tag, tag) for tag in entry["meal_tags"]]
entry["is_personal"] = entry.get("visibility") == "personal"
entry["is_shared"] = entry.get("visibility") == "shared"
entry["is_mine"] = entry.get("owner_user_id") == g.user["id"]
@@ -516,7 +594,6 @@ def attach_builder_keys(items: list[dict]) -> list[dict]:
if not items:
return []
category_builder_map = get_category_builder_map()
meal_ids = [item["id"] for item in items if item["kind"] == "meal"]
meal_builder_map: dict[int, set[str]] = defaultdict(set)
@@ -525,7 +602,7 @@ def attach_builder_keys(items: list[dict]) -> list[dict]:
rows = get_db().execute(
f"""
SELECT meal_components.meal_item_id,
component.category
component.base_type
FROM meal_components
JOIN items AS component ON component.id = meal_components.food_item_id
WHERE meal_components.meal_item_id IN ({placeholders})
@@ -533,7 +610,7 @@ def attach_builder_keys(items: list[dict]) -> list[dict]:
meal_ids,
).fetchall()
for row in rows:
builder_key = category_builder_map.get(row["category"] or "", "neutral")
builder_key = normalize_base_type(row["base_type"], "neutral")
meal_builder_map[int(row["meal_item_id"])].add(builder_key)
for item in items:
@@ -541,9 +618,9 @@ def attach_builder_keys(items: list[dict]) -> list[dict]:
if item["kind"] == "meal":
builder_keys = sorted(meal_builder_map.get(item["id"], set()))
if not builder_keys:
builder_keys = [category_builder_map.get(item.get("category") or "", "neutral")]
builder_keys = [normalize_base_type(item.get("base_type"), "neutral")]
else:
builder_keys = [category_builder_map.get(item.get("category") or "", "neutral")]
builder_keys = [normalize_base_type(item.get("base_type"), "neutral")]
item["builder_keys"] = builder_keys
item["builder_labels"] = [BUILDER_LABELS.get(key, BUILDER_LABELS["neutral"]) for key in builder_keys]
item["primary_builder_key"] = builder_keys[0] if builder_keys else "neutral"
@@ -557,23 +634,22 @@ def decorate_items(rows) -> list[dict]:
def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]:
if not item_ids:
return {}
category_builder_map = get_category_builder_map()
placeholders = ",".join("?" for _ in item_ids)
rows = get_db().execute(
f"""
SELECT id, kind, category
SELECT id, kind, base_type
FROM items
WHERE id IN ({placeholders})
""",
item_ids,
).fetchall()
builder_map: dict[int, set[str]] = {int(row["id"]): {category_builder_map.get(row["category"] or "", "neutral")} for row in rows}
builder_map: dict[int, set[str]] = {int(row["id"]): {normalize_base_type(row["base_type"], "neutral")} for row in rows}
meal_ids = [int(row["id"]) for row in rows if row["kind"] == "meal"]
if meal_ids:
meal_placeholders = ",".join("?" for _ in meal_ids)
component_rows = get_db().execute(
f"""
SELECT meal_components.meal_item_id, component.category
SELECT meal_components.meal_item_id, component.base_type
FROM meal_components
JOIN items AS component ON component.id = meal_components.food_item_id
WHERE meal_components.meal_item_id IN ({meal_placeholders})
@@ -582,15 +658,74 @@ def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]:
).fetchall()
for row in component_rows:
builder_map.setdefault(int(row["meal_item_id"]), set()).add(
category_builder_map.get(row["category"] or "", "neutral")
normalize_base_type(row["base_type"], "neutral")
)
return builder_map
def suggestion_priority_score(priority: str) -> int:
return {
"prefer": 8,
"normal": 3,
"rare": -6,
"never": -50,
}.get(priority, 0)
def is_animal_protein_item(item: dict) -> bool:
normalized = (item.get("name") or "").strip().lower()
return any(
token in normalized
for token in ("huhn", "hähn", "rind", "schwein", "speck", "salami", "wurst", "thunfisch", "lachs", "fisch", "garnelen", "shrimp", "sardinen")
)
def protein_preference_score(item: dict, settings: dict) -> int:
preference = normalize_protein_preference(settings.get("protein_preference"), "mixed")
if not is_animal_protein_item(item):
return 2 if preference in {"veg-friendly", "rare-animal", "plant-forward"} else 0
if preference == "mixed":
return 0
if preference == "veg-friendly":
return -4
if preference == "rare-animal":
return -8
if preference == "plant-forward":
return -14
return 0
def meaningful_component(item: dict) -> bool:
role = normalize_food_role(item.get("suggestion_role"), "base")
if role in {"topping", "cooking"}:
return False
return bool(item.get("can_be_meal_core")) or role in {"base", "main", "solo", "snack", "complement"}
def food_supports_slot(food: dict, slot: dict) -> bool:
if normalize_suggestion_priority(food.get("suggestion_priority"), "normal") == "never":
return False
if slot.get("core_only") and not bool(food.get("can_be_meal_core")):
return False
role = normalize_food_role(food.get("suggestion_role"), "base")
if slot.get("roles") and role not in slot["roles"]:
return False
base_type = normalize_base_type(food.get("base_type"), "neutral")
accepted = set(slot.get("base_types", set()))
if "fiber" in accepted and base_type in {"veg", "fruit"}:
return True
return base_type in accepted
def score_suggestion_components(component_items: list[dict], daypart_slug: str, settings: dict) -> int:
builder_keys = {key for item in component_items for key in item.get("builder_keys", ["neutral"])}
meaningful_items = [item for item in component_items if meaningful_component(item)]
builder_keys = {key for item in meaningful_items for key in item.get("builder_keys", ["neutral"])}
energy_values = [normalize_energy_density(item.get("energy_density"), "neutral") for item in component_items]
score = 0
score = sum(suggestion_priority_score(normalize_suggestion_priority(item.get("suggestion_priority"), "normal")) for item in component_items)
score += sum(protein_preference_score(item, settings) for item in component_items)
score += sum(2 for item in component_items if bool(item.get("can_be_meal_core")))
style = settings.get("suggestion_style", "balanced")
if style == "fitness":
@@ -600,6 +735,12 @@ def score_suggestion_components(component_items: list[dict], daypart_slug: str,
elif style == "protein":
score += 8 if "protein" in builder_keys else 0
score += 3 if daypart_slug in {"lunch", "dinner"} and "veg" in builder_keys else 0
elif style == "easy":
score += 5 if any(normalize_food_role(item.get("suggestion_role"), "base") in {"solo", "base"} for item in component_items) else 0
score += 4 if len(component_items) <= 3 else -2
elif style == "snack":
score += 5 if daypart_slug in {"morning-snack", "afternoon-snack", "late-snack"} else 0
score += 3 if any(item.get("base_type") in {"fruit", "dairy", "nuts", "seeds"} for item in component_items) else 0
else:
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
score += 5 if "carb" in builder_keys else 0
@@ -607,7 +748,7 @@ def score_suggestion_components(component_items: list[dict], daypart_slug: str,
else:
score += 5 if "protein" in builder_keys else 0
score += 4 if "carb" in builder_keys else 0
score += 4 if "veg" in builder_keys else 0
score += 4 if builder_keys & {"veg", "fruit"} else 0
energy_preference = settings.get("energy_preference", "neutral")
if style == "fitness":
@@ -709,20 +850,44 @@ def group_items_by_availability(items: list[dict]) -> list[dict]:
def extract_item_form_data(existing: dict | None = None) -> dict:
form_data = existing or {}
daypart_ids = [int(value) for value in request.form.getlist("daypart_ids") if value.isdigit()]
meal_type_default = form_data.get("meal_type") or meal_type_for_daypart(daypart_ids[0] if daypart_ids else None)
form_data.update(
{
"name": request.form.get("name", "").strip(),
"category": request.form.get("category", "").strip(),
"base_type": normalize_base_type(request.form.get("base_type"), form_data.get("base_type", "neutral")),
"suggestion_role": normalize_food_role(request.form.get("suggestion_role"), form_data.get("suggestion_role", "base")),
"suggestion_priority": normalize_suggestion_priority(
request.form.get("suggestion_priority"),
form_data.get("suggestion_priority", "normal"),
),
"can_be_meal_core": request.form.get("can_be_meal_core", "0") == "1",
"meal_type": normalize_meal_type(request.form.get("meal_type"), meal_type_default),
"meal_tags": normalize_meal_tags(request.form.getlist("meal_tags")),
"energy_density": normalize_energy_density(request.form.get("energy_density"), form_data.get("energy_density", "neutral")),
"note": request.form.get("note", "").strip(),
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")),
"target_user_id": normalize_target_user_id(request.form.get("target_user_id")),
"target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT),
"food_search": request.form.get("food_search", "").strip(),
"daypart_ids": [int(value) for value in request.form.getlist("daypart_ids") if value.isdigit()],
"daypart_ids": daypart_ids,
"component_ids": [int(value) for value in request.form.getlist("component_ids") if value.isdigit()],
"quick_food_name": request.form.get("quick_food_name", "").strip(),
"quick_food_category": request.form.get("quick_food_category", "").strip(),
"quick_food_base_type": normalize_base_type(
request.form.get("quick_food_base_type"),
form_data.get("quick_food_base_type", "neutral"),
),
"quick_food_role": normalize_food_role(
request.form.get("quick_food_role"),
form_data.get("quick_food_role", "base"),
),
"quick_food_priority": normalize_suggestion_priority(
request.form.get("quick_food_priority"),
form_data.get("quick_food_priority", "normal"),
),
"quick_food_can_be_meal_core": request.form.get("quick_food_can_be_meal_core", "0") == "1",
"quick_food_energy_density": normalize_energy_density(request.form.get("quick_food_energy_density"), form_data.get("quick_food_energy_density", "neutral")),
"quick_food_note": request.form.get("quick_food_note", "").strip(),
}
@@ -734,9 +899,9 @@ def create_quick_food_from_form(form_data: dict) -> int:
cursor = get_db().execute(
"""
INSERT INTO items (
household_id, owner_user_id, target_user_id, visibility, kind, name, category, energy_density, note, created_by, updated_by
household_id, owner_user_id, target_user_id, visibility, kind, name, category, base_type, suggestion_role, suggestion_priority, can_be_meal_core, energy_density, note, created_by, updated_by
)
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
current_household_id(),
@@ -745,6 +910,10 @@ def create_quick_food_from_form(form_data: dict) -> int:
form_data["visibility"],
form_data["quick_food_name"],
form_data["quick_food_category"],
form_data["quick_food_base_type"],
form_data["quick_food_role"],
form_data["quick_food_priority"],
1 if form_data["quick_food_can_be_meal_core"] else 0,
form_data["quick_food_energy_density"],
form_data["quick_food_note"],
g.user["id"],
@@ -1269,6 +1438,41 @@ def get_daypart_by_id(daypart_id: int):
return None
def meal_type_for_daypart(daypart_id: int | None) -> str:
daypart = get_daypart_by_id(daypart_id) if daypart_id else None
if not daypart:
return "snack"
return DAYPART_SLUG_TO_MEAL_TYPE.get(daypart["slug"], "snack")
def meal_tags_for_generated_meal(daypart_id: int, foods: list[dict]) -> list[str]:
daypart = get_daypart_by_id(daypart_id)
slug = daypart["slug"] if daypart else ""
tags: list[str] = []
if slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
if any(item.get("base_type") in {"fruit", "dairy"} for item in foods):
tags.append("sweet")
if any(item.get("base_type") == "carb" for item in foods):
tags.append("simple")
else:
if any(item.get("base_type") in {"protein", "veg"} for item in foods):
tags.append("savory")
if any(item.get("suggestion_role") == "solo" for item in foods):
tags.append("quick")
if any(item.get("base_type") in {"dairy", "fruit"} for item in foods):
tags.append("cold")
if any(item.get("base_type") in {"protein", "veg"} for item in foods) and slug in {"lunch", "dinner"}:
tags.append("warm")
normalized: list[str] = []
for tag in tags:
if tag not in normalized:
normalized.append(tag)
return normalized
def format_item_names(items: list[dict], limit: int = 3) -> str:
return ", ".join(item["name"] for item in items[:limit])
@@ -1312,56 +1516,110 @@ def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str:
return names[0]
def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]:
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
target_patterns = [
def meal_pattern_definitions(daypart_slug: str) -> list[dict]:
if daypart_slug == "breakfast":
return [
{
"slots": ({"carb"}, {"dairy", "protein"}, {"fruit", "nuts", "seeds"}),
"reason": "Passt gut zu Frühstück oder Snack",
"reason": "Passt gut zu Frühstück",
"slots": [
{"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True},
{"base_types": {"dairy", "protein"}, "roles": {"base", "main", "complement", "solo", "snack"}, "core_only": False},
{"base_types": {"fruit"}, "roles": {"complement", "topping", "snack", "base"}, "core_only": False},
],
},
{
"reason": "Passt gut für Frühstück",
"slots": [
{"base_types": {"dairy"}, "roles": {"base", "main", "solo", "snack"}, "core_only": True},
{"base_types": {"carb"}, "roles": {"base", "main", "complement", "solo"}, "core_only": False},
{"base_types": {"nuts", "seeds", "fruit"}, "roles": {"topping", "complement", "snack"}, "core_only": False},
],
},
]
if daypart_slug in {"morning-snack", "afternoon-snack", "late-snack"}:
return [
{
"reason": "Passt gut zu einem kleinen Snack",
"slots": [
{"base_types": {"dairy"}, "roles": {"base", "solo", "snack"}, "core_only": True},
{"base_types": {"fruit"}, "roles": {"complement", "snack", "topping"}, "core_only": False},
],
},
{
"slots": ({"carb"}, {"dairy", "protein"}),
"reason": "Zuhause schnell kombinierbar",
"slots": [
{"base_types": {"fruit"}, "roles": {"base", "snack", "complement"}, "core_only": True},
{"base_types": {"nuts", "seeds"}, "roles": {"topping", "snack", "complement"}, "core_only": False},
],
},
{
"slots": ({"dairy", "protein"}, {"fruit", "nuts", "seeds"}),
"reason": "Lässt sich gut als kleiner Snack vormerken",
},
]
else:
target_patterns = [
{
"slots": ({"protein"}, {"carb"}, {"veg"}),
"reason": "Zuhause als vollständige Mahlzeit möglich",
},
{
"slots": ({"protein"}, {"carb"}),
"reason": "Lässt sich leicht ergänzen",
},
{
"slots": ({"protein"}, {"veg"}),
"reason": "Zuhause schon gut kombinierbar",
},
{
"slots": ({"carb"}, {"veg"}),
"reason": "Daraus kann schnell etwas Einfaches werden",
"reason": "Passt gut zu einem kleinen Snack",
"slots": [
{"base_types": {"carb"}, "roles": {"solo", "base", "snack"}, "core_only": True},
{"base_types": {"protein", "dairy"}, "roles": {"complement", "snack", "base"}, "core_only": False},
],
},
]
return [
{
"reason": "Zuhause als vollständige Mahlzeit möglich",
"slots": [
{"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True},
{"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True},
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False},
],
},
{
"reason": "Lässt sich gut ergänzen",
"slots": [
{"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True},
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False},
{"base_types": {"carb"}, "roles": {"base", "complement", "solo"}, "core_only": False},
],
},
{
"reason": "Schnell und alltagstauglich",
"slots": [
{"base_types": {"carb", "protein"}, "roles": {"solo"}, "core_only": True},
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False},
],
},
]
def score_food_for_pattern(food: dict, settings: dict) -> int:
score = suggestion_priority_score(normalize_suggestion_priority(food.get("suggestion_priority"), "normal"))
score += protein_preference_score(food, settings)
if bool(food.get("can_be_meal_core")):
score += 3
role = normalize_food_role(food.get("suggestion_role"), "base")
if role == "main":
score += 3
elif role == "base":
score += 2
elif role == "solo":
score += 4
return score
def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]:
settings = get_user_settings()
target_patterns = meal_pattern_definitions(daypart_slug)
suggestions: list[dict] = []
seen_signatures: set[tuple[int, ...]] = set()
def slot_matches(food: dict, slot_keys: set[str]) -> bool:
return bool(slot_keys & set(food.get("builder_keys", ["neutral"])))
for pattern in target_patterns:
slot_candidates = []
for slot_keys in pattern["slots"]:
matches = [food for food in home_foods if slot_matches(food, slot_keys)]
for slot in pattern["slots"]:
matches = [food for food in home_foods if food_supports_slot(food, slot)]
matches = sorted(matches, key=lambda food: (-score_food_for_pattern(food, settings), food["name"].lower()))
if not matches:
slot_candidates = []
break
slot_candidates.append(matches)
slot_candidates.append(matches[:6])
if not slot_candidates:
continue
@@ -1384,6 +1642,7 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
"needs_shopping": False,
"is_generated": True,
"suggestion_key": generated_suggestion_key([item["id"] for item in combo_items]),
"score": score_suggestion_components(combo_items, daypart_slug=daypart_slug, settings=settings),
}
)
if len(suggestions) >= limit * 3:
@@ -1511,14 +1770,25 @@ def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | Non
present = set()
for keys in builder_map.values():
present.update(keys)
target_order = ["protein", "carb", "veg"]
missing = [key for key in target_order if key not in present]
has_fiber = bool(present & {"veg", "fruit"})
missing = []
if "protein" not in present:
missing.append("protein")
if "carb" not in present:
missing.append("carb")
if not has_fiber:
missing.append("fiber")
if not missing:
return None
first_missing = missing[0]
home_matches = [
item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id)
if first_missing in item.get("builder_keys", [])
if (
(first_missing == "fiber" and bool(set(item.get("builder_keys", [])) & {"veg", "fruit"}))
or first_missing in item.get("builder_keys", [])
)
and meaningful_component(item)
and normalize_suggestion_priority(item.get("suggestion_priority"), "normal") != "never"
]
home_matches = sorted(
home_matches,
@@ -1527,7 +1797,7 @@ def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | Non
text_map = {
"protein": "Dazu könnte noch eine Proteinquelle gut passen.",
"carb": "Das lässt sich gut mit einer Kohlenhydratquelle ergänzen.",
"veg": "Dazu könnte noch etwas Gemüse gut passen.",
"fiber": "Dazu könnte noch etwas Gemüse oder Obst gut passen.",
}
return {
"text": text_map.get(first_missing, "Dazu könnte noch etwas Kleines gut passen."),
@@ -1598,7 +1868,7 @@ def build_dashboard_hints(today: date) -> list[str]:
visible_params(),
).fetchone()
if int(dinner_home["count"]) > 0:
hints.append("Zuhause ist bereits etwas da, das gut zu Abendessen passt.")
hints.append("Für heute Abend ist zuhause schon etwas Passendes da.")
if settings.get("remind_before_shopping") and (today + timedelta(days=1)).weekday() == household_settings["shopping_weekday"]:
upcoming = fetch_upcoming_shopping_needs(limit=3)
@@ -1608,7 +1878,7 @@ def build_dashboard_hints(today: date) -> list[str]:
if settings.get("remind_nuts"):
nut_items = [item for item in fetch_items(kind="food", availability="home") if {"nuts", "seeds"} & set(item.get("builder_keys", []))]
if nut_items:
hints.append("Heute schon an Nüsse gedacht?")
hints.append("Vielleicht passt heute noch etwas mit Nüssen oder Saaten dazu.")
if settings.get("suggest_templates"):
old_template = get_db().execute(
@@ -2580,6 +2850,12 @@ def render_item_form(kind: str, *, item: dict | None, form_data: dict):
form_data.get("category") or form_data.get("quick_food_category")
),
form_data=form_data,
builder_options=[(key, label) for key, label in BUILDER_LABELS.items()],
food_role_options=FOOD_ROLE_OPTIONS,
food_role_descriptions=FOOD_ROLE_DESCRIPTIONS,
suggestion_priority_options=SUGGESTION_PRIORITY_OPTIONS,
meal_type_options=MEAL_TYPE_OPTIONS,
meal_style_options=MEAL_STYLE_OPTIONS,
energy_density_options=ENERGY_DENSITY_OPTIONS,
visibility_options=VISIBILITY_FORM_OPTIONS,
target_user_options=get_target_user_options(),
@@ -2634,27 +2910,52 @@ def create_or_get_generated_meal(
get_db().execute(
"""
UPDATE items
SET updated_by = ?, updated_at = CURRENT_TIMESTAMP
SET meal_type = COALESCE(meal_type, ?),
meal_tags = CASE
WHEN COALESCE(meal_tags, '') = '' THEN ?
ELSE meal_tags
END,
updated_by = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(g.user["id"], meal_id),
(
meal_type_for_daypart(daypart_id),
encode_tag_list(meal_tags_for_generated_meal(daypart_id, fetch_items_by_ids(list(normalized_ids)))),
g.user["id"],
meal_id,
),
)
get_db().commit()
return meal_id
component_foods = fetch_items_by_ids(list(normalized_ids))
cursor = get_db().execute(
"""
INSERT INTO items (
household_id, owner_user_id, visibility, kind, name, category, created_by, updated_by
household_id,
owner_user_id,
visibility,
kind,
name,
category,
meal_type,
meal_tags,
energy_density,
created_by,
updated_by
)
VALUES (?, ?, ?, 'meal', ?, ?, ?, ?)
VALUES (?, ?, ?, 'meal', ?, ?, ?, ?, ?, ?, ?)
""",
(
current_household_id(),
g.user["id"],
visibility,
name,
"Kleines Essen" if get_daypart_by_id(daypart_id)["slug"] in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} else "Warmes",
None,
meal_type_for_daypart(daypart_id),
encode_tag_list(meal_tags_for_generated_meal(daypart_id, component_foods)),
"neutral",
g.user["id"],
g.user["id"],
),
@@ -3124,6 +3425,7 @@ def settings_view():
elif form_name == "reminders":
ensure_user_settings_row()
suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced")
protein_preference = normalize_protein_preference(request.form.get("protein_preference"), "mixed")
get_db().execute(
"""
UPDATE user_settings
@@ -3132,6 +3434,7 @@ def settings_view():
notification_channel = ?,
suggestion_style = ?,
energy_preference = ?,
protein_preference = ?,
remind_before_shopping = ?,
remind_on_shopping_day = ?,
show_missing_for_upcoming_week = ?,
@@ -3157,6 +3460,7 @@ def settings_view():
normalize_notification_channel(request.form.get("notification_channel"), "in_app"),
suggestion_style,
suggestion_style_energy_preference(suggestion_style),
protein_preference,
parse_checkbox("remind_before_shopping", True),
parse_checkbox("remind_on_shopping_day", True),
parse_checkbox("show_missing_for_upcoming_week", True),
@@ -3379,6 +3683,12 @@ def item_create(kind: str):
form_data = {
"name": request.args.get("name", "").strip(),
"category": "",
"base_type": "neutral",
"suggestion_role": "base",
"suggestion_priority": "normal",
"can_be_meal_core": False,
"meal_type": normalize_meal_type(request.args.get("meal_type"), "snack"),
"meal_tags": [],
"energy_density": "neutral",
"note": "",
"visibility": "shared",
@@ -3389,6 +3699,10 @@ def item_create(kind: str):
"component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()],
"quick_food_name": "",
"quick_food_category": "",
"quick_food_base_type": "neutral",
"quick_food_role": "base",
"quick_food_priority": "normal",
"quick_food_can_be_meal_core": False,
"quick_food_energy_density": "neutral",
"quick_food_note": "",
}
@@ -3428,9 +3742,9 @@ def item_create(kind: str):
cursor = get_db().execute(
"""
INSERT INTO items (
household_id, owner_user_id, target_user_id, visibility, kind, name, category, energy_density, note, photo_filename, created_by, updated_by
household_id, owner_user_id, target_user_id, visibility, kind, name, category, base_type, suggestion_role, suggestion_priority, can_be_meal_core, meal_type, meal_tags, energy_density, note, photo_filename, created_by, updated_by
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
current_household_id(),
@@ -3439,7 +3753,13 @@ def item_create(kind: str):
form_data["visibility"],
kind,
form_data["name"],
form_data["category"],
form_data["category"] if kind == "food" else None,
form_data["base_type"] if kind == "food" else "neutral",
form_data["suggestion_role"] if kind == "food" else "base",
form_data["suggestion_priority"] if kind == "food" else "normal",
1 if (form_data["can_be_meal_core"] if kind == "food" else False) else 0,
form_data["meal_type"] if kind == "meal" else None,
encode_tag_list(form_data["meal_tags"]) if kind == "meal" else "",
form_data["energy_density"],
form_data["note"],
photo_filename,
@@ -3472,6 +3792,12 @@ def item_edit(item_id: int):
form_data = {
"name": item["name"],
"category": item["category"] or "",
"base_type": item.get("base_type") or "neutral",
"suggestion_role": item.get("suggestion_role") or "base",
"suggestion_priority": item.get("suggestion_priority") or "normal",
"can_be_meal_core": bool(item.get("can_be_meal_core")),
"meal_type": item.get("meal_type") or meal_type_for_daypart(item.get("primary_daypart_id")),
"meal_tags": decode_tag_list(item.get("meal_tags")),
"energy_density": item.get("energy_density") or "neutral",
"note": item["note"] or "",
"visibility": item["visibility"],
@@ -3482,6 +3808,10 @@ def item_edit(item_id: int):
"component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [],
"quick_food_name": "",
"quick_food_category": "",
"quick_food_base_type": "neutral",
"quick_food_role": "base",
"quick_food_priority": "normal",
"quick_food_can_be_meal_core": False,
"quick_food_energy_density": "neutral",
"quick_food_note": "",
}
@@ -3523,6 +3853,12 @@ def item_edit(item_id: int):
UPDATE items
SET name = ?,
category = ?,
base_type = ?,
suggestion_role = ?,
suggestion_priority = ?,
can_be_meal_core = ?,
meal_type = ?,
meal_tags = ?,
energy_density = ?,
note = ?,
visibility = ?,
@@ -3534,7 +3870,13 @@ def item_edit(item_id: int):
""",
(
form_data["name"],
form_data["category"],
form_data["category"] if item["kind"] == "food" else None,
form_data["base_type"] if item["kind"] == "food" else "neutral",
form_data["suggestion_role"] if item["kind"] == "food" else "base",
form_data["suggestion_priority"] if item["kind"] == "food" else "normal",
1 if (form_data["can_be_meal_core"] if item["kind"] == "food" else False) else 0,
form_data["meal_type"] if item["kind"] == "meal" else None,
encode_tag_list(form_data["meal_tags"]) if item["kind"] == "meal" else "",
form_data["energy_density"],
form_data["note"],
form_data["visibility"],