3 Commits

Author SHA1 Message Date
hnzio c5dea16c53 Shorten meal edit headers 2026-04-13 19:30:40 +02:00
hnzio e057cf0382 Clarify meal editing and selected ingredients 2026-04-13 19:29:15 +02:00
hnzio 5a1c1d5c41 Filter meal suggestions by simple flavor profiles 2026-04-13 18:47:39 +02:00
6 changed files with 357 additions and 57 deletions
+18
View File
@@ -67,6 +67,24 @@ BUILDER_DESCRIPTIONS = {
BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()] BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()]
FOOD_FLAVOR_OPTIONS = [
("neutral", "Neutral"),
("sweet", "Süß"),
("savory", "Herzhaft"),
]
FOOD_FLAVOR_LABELS = {
"neutral": "Neutral",
"sweet": "Süß",
"savory": "Herzhaft",
}
FOOD_FLAVOR_DESCRIPTIONS = {
"neutral": "Passt ruhig in beide Richtungen und bleibt flexibel.",
"sweet": "Passt eher zu süßen Kombinationen, Frühstücksideen oder kleinen Snacks.",
"savory": "Passt eher zu herzhaften Kombinationen und ruhigeren Hauptmahlzeiten.",
}
FOOD_ROLE_LABELS = { FOOD_ROLE_LABELS = {
"main": "Hauptbaustein", "main": "Hauptbaustein",
"base": "Basis", "base": "Basis",
+73
View File
@@ -39,6 +39,39 @@ def normalize_name_for_profile(name: str | None) -> str:
return (name or "").strip().lower() return (name or "").strip().lower()
def infer_food_flavor_profile(
name: str | None,
category: str | None,
base_type: str | None = None,
suggestion_role: str | None = None,
) -> str:
normalized_name = normalize_name_for_profile(name)
normalized_category = (category or "").strip().lower()
normalized_base_type = (base_type or "").strip().lower()
normalized_role = (suggestion_role or "").strip().lower()
if any(token in normalized_name for token in ("proteinpulver", "eiweißpulver", "whey", "clear whey")):
return "neutral"
if any(token in normalized_name for token in ("schoko", "choco", "müsli", "granola", "cornflakes", "fruchtjoghurt", "vanillejoghurt", "pudding")):
return "sweet"
if any(token in normalized_name for token in ("banane", "apfel", "birne", "beeren", "himbeer", "erdbeer", "heidelbeer", "mango", "kiwi", "trauben")):
return "sweet"
if any(token in normalized_name for token in ("räucher", "tofu", "tempeh", "hack", "salami", "wurst", "thunfisch", "lachs", "fisch", "huhn", "hähn", "rind", "schwein", "aufstrich", "pesto", "humus", "hummus", "reisgericht", "chili", "curry")):
return "savory"
if any(token in normalized_name for token in ("naturjoghurt", "joghurt natur", "quark", "skyr", "haferflocken", "gurke", "karotte", "karotten", "kartoffel", "kartoffeln", "reis", "nudeln", "brot", "brötchen")):
return "neutral"
if "obst" in normalized_category or normalized_base_type == "fruit":
return "sweet"
if any(token in normalized_category for token in ("eiweiß", "protein")) or normalized_base_type == "protein":
return "savory"
if any(token in normalized_category for token in ("gemüse",)) or normalized_base_type in {"veg", "carb", "dairy", "nuts", "seeds"}:
return "neutral"
if normalized_role in {"topping", "cooking"}:
return "neutral"
return "neutral"
def infer_food_profile(name: str | None, category: str | None, energy_density: str | None) -> dict[str, object]: def infer_food_profile(name: str | None, category: str | None, energy_density: str | None) -> dict[str, object]:
normalized_name = normalize_name_for_profile(name) normalized_name = normalize_name_for_profile(name)
normalized_category = (category or "").strip().lower() normalized_category = (category or "").strip().lower()
@@ -275,6 +308,42 @@ def migrate_item_profiles(database: sqlite3.Connection) -> None:
) )
def migrate_food_flavor_profiles(database: sqlite3.Connection) -> None:
if get_meta(database, "food_flavor_profiles_migrated") == "1":
return
rows = database.execute(
"""
SELECT id, name, category, base_type, suggestion_role, flavor_profile
FROM items
WHERE kind = 'food'
ORDER BY id
"""
).fetchall()
for row in rows:
current_flavor = (row["flavor_profile"] or "").strip().lower()
if current_flavor in {"sweet", "savory"}:
continue
database.execute(
"""
UPDATE items
SET flavor_profile = ?
WHERE id = ?
""",
(
infer_food_flavor_profile(
row["name"],
row["category"],
row["base_type"],
row["suggestion_role"],
),
int(row["id"]),
),
)
set_meta(database, "food_flavor_profiles_migrated", "1")
def get_db() -> sqlite3.Connection: def get_db() -> sqlite3.Connection:
if "db" not in g: if "db" not in g:
g.db = sqlite3.connect( g.db = sqlite3.connect(
@@ -392,6 +461,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
add_column_if_missing(database, "items", "target_user_id INTEGER") add_column_if_missing(database, "items", "target_user_id INTEGER")
add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'") add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'")
add_column_if_missing(database, "items", "base_type TEXT NOT NULL DEFAULT 'neutral'") add_column_if_missing(database, "items", "base_type TEXT NOT NULL DEFAULT 'neutral'")
add_column_if_missing(database, "items", "flavor_profile TEXT NOT NULL DEFAULT 'neutral'")
add_column_if_missing(database, "items", "suggestion_role TEXT NOT NULL DEFAULT 'base'") add_column_if_missing(database, "items", "suggestion_role TEXT NOT NULL DEFAULT 'base'")
add_column_if_missing(database, "items", "suggestion_priority TEXT NOT NULL DEFAULT 'normal'") add_column_if_missing(database, "items", "suggestion_priority TEXT NOT NULL DEFAULT 'normal'")
add_column_if_missing(database, "items", "can_be_meal_core INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "items", "can_be_meal_core INTEGER NOT NULL DEFAULT 0")
@@ -645,6 +715,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'") add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'")
add_column_if_missing(database, "items", "target_user_id INTEGER") add_column_if_missing(database, "items", "target_user_id INTEGER")
add_column_if_missing(database, "items", "base_type TEXT NOT NULL DEFAULT 'neutral'") add_column_if_missing(database, "items", "base_type TEXT NOT NULL DEFAULT 'neutral'")
add_column_if_missing(database, "items", "flavor_profile TEXT NOT NULL DEFAULT 'neutral'")
add_column_if_missing(database, "items", "suggestion_role TEXT NOT NULL DEFAULT 'base'") add_column_if_missing(database, "items", "suggestion_role TEXT NOT NULL DEFAULT 'base'")
add_column_if_missing(database, "items", "suggestion_priority TEXT NOT NULL DEFAULT 'normal'") add_column_if_missing(database, "items", "suggestion_priority TEXT NOT NULL DEFAULT 'normal'")
add_column_if_missing(database, "items", "can_be_meal_core INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "items", "can_be_meal_core INTEGER NOT NULL DEFAULT 0")
@@ -699,6 +770,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
sync_default_categories(database) sync_default_categories(database)
migrate_item_profiles(database) migrate_item_profiles(database)
migrate_food_flavor_profiles(database)
database.execute( database.execute(
""" """
INSERT OR IGNORE INTO user_settings (user_id) INSERT OR IGNORE INTO user_settings (user_id)
@@ -707,6 +779,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
) )
database.execute("UPDATE items SET energy_density = 'neutral' WHERE energy_density IS NULL OR energy_density = ''") database.execute("UPDATE items SET energy_density = 'neutral' WHERE energy_density IS NULL OR energy_density = ''")
database.execute("UPDATE items SET base_type = 'neutral' WHERE base_type IS NULL OR base_type = ''") database.execute("UPDATE items SET base_type = 'neutral' WHERE base_type IS NULL OR base_type = ''")
database.execute("UPDATE items SET flavor_profile = 'neutral' WHERE flavor_profile IS NULL OR flavor_profile = ''")
database.execute("UPDATE items SET suggestion_role = 'base' WHERE suggestion_role IS NULL OR suggestion_role = ''") database.execute("UPDATE items SET suggestion_role = 'base' WHERE suggestion_role IS NULL OR suggestion_role = ''")
database.execute("UPDATE items SET suggestion_priority = 'normal' WHERE suggestion_priority IS NULL OR suggestion_priority = ''") database.execute("UPDATE items SET suggestion_priority = 'normal' WHERE suggestion_priority IS NULL OR suggestion_priority = ''")
database.execute("UPDATE items SET can_be_meal_core = 0 WHERE can_be_meal_core IS NULL") database.execute("UPDATE items SET can_be_meal_core = 0 WHERE can_be_meal_core IS NULL")
+139 -32
View File
@@ -33,6 +33,9 @@ from .constants import (
DEFAULT_CATEGORIES, DEFAULT_CATEGORIES,
ENERGY_DENSITY_LABELS, ENERGY_DENSITY_LABELS,
ENERGY_DENSITY_OPTIONS, ENERGY_DENSITY_OPTIONS,
FOOD_FLAVOR_DESCRIPTIONS,
FOOD_FLAVOR_LABELS,
FOOD_FLAVOR_OPTIONS,
FOOD_ROLE_DESCRIPTIONS, FOOD_ROLE_DESCRIPTIONS,
FOOD_ROLE_LABELS, FOOD_ROLE_LABELS,
FOOD_ROLE_OPTIONS, FOOD_ROLE_OPTIONS,
@@ -311,6 +314,11 @@ def normalize_base_type(raw: str | None, default: str = "neutral") -> str:
return raw if raw in allowed else default return raw if raw in allowed else default
def normalize_food_flavor(raw: str | None, default: str = "neutral") -> str:
allowed = {value for value, _label in FOOD_FLAVOR_OPTIONS}
return raw if raw in allowed else default
def normalize_food_role(raw: str | None, default: str = "base") -> str: def normalize_food_role(raw: str | None, default: str = "base") -> str:
allowed = {value for value, _label in FOOD_ROLE_OPTIONS} allowed = {value for value, _label in FOOD_ROLE_OPTIONS}
return raw if raw in allowed else default return raw if raw in allowed else default
@@ -428,6 +436,8 @@ def describe_record(entry: dict) -> dict:
entry["energy_density_label"] = ENERGY_DENSITY_LABELS.get(entry["energy_density"], ENERGY_DENSITY_LABELS["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"] = normalize_base_type(entry.get("base_type"), "neutral")
entry["base_type_label"] = BUILDER_LABELS.get(entry["base_type"], BUILDER_LABELS["neutral"]) entry["base_type_label"] = BUILDER_LABELS.get(entry["base_type"], BUILDER_LABELS["neutral"])
entry["flavor_profile"] = normalize_food_flavor(entry.get("flavor_profile"), "neutral")
entry["flavor_profile_label"] = FOOD_FLAVOR_LABELS.get(entry["flavor_profile"], FOOD_FLAVOR_LABELS["neutral"])
entry["suggestion_role"] = normalize_food_role(entry.get("suggestion_role"), "base") 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_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"] = normalize_suggestion_priority(entry.get("suggestion_priority"), "normal")
@@ -714,12 +724,29 @@ def food_supports_slot(food: dict, slot: dict) -> bool:
base_type = normalize_base_type(food.get("base_type"), "neutral") base_type = normalize_base_type(food.get("base_type"), "neutral")
accepted = set(slot.get("base_types", set())) accepted = set(slot.get("base_types", set()))
if "fiber" in accepted and base_type in {"veg", "fruit"}: matches_base_type = ("fiber" in accepted and base_type in {"veg", "fruit"}) or base_type in accepted
if not matches_base_type:
return False
accepted_flavors = set(slot.get("flavors", set()))
if not accepted_flavors:
return True return True
return base_type in accepted return normalize_food_flavor(food.get("flavor_profile"), "neutral") in accepted_flavors
def components_are_flavor_compatible(component_items: list[dict]) -> bool:
flavors = {
normalize_food_flavor(item.get("flavor_profile"), "neutral")
for item in component_items
if meaningful_component(item)
}
return not ({"sweet", "savory"} <= flavors)
def score_suggestion_components(component_items: list[dict], daypart_slug: str, settings: dict) -> int: def score_suggestion_components(component_items: list[dict], daypart_slug: str, settings: dict) -> int:
if not components_are_flavor_compatible(component_items):
return -999
meaningful_items = [item for item in component_items if meaningful_component(item)] 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"])} 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] energy_values = [normalize_energy_density(item.get("energy_density"), "neutral") for item in component_items]
@@ -848,22 +875,27 @@ def group_items_by_availability(items: list[dict]) -> list[dict]:
return result return result
def extract_item_form_data(existing: dict | None = None) -> dict: def extract_item_form_data(kind: str, existing: dict | None = None) -> dict:
form_data = existing or {} form_data = existing or {}
daypart_ids = [int(value) for value in request.form.getlist("daypart_ids") if value.isdigit()] 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) meal_type_default = form_data.get("meal_type") or meal_type_for_daypart(daypart_ids[0] if daypart_ids else None)
meal_type = normalize_meal_type(request.form.get("meal_type"), meal_type_default)
if kind == "meal":
daypart_ids = daypart_ids_for_meal_type(meal_type)
component_ids = list(dict.fromkeys(int(value) for value in request.form.getlist("component_ids") if value.isdigit()))
form_data.update( form_data.update(
{ {
"name": request.form.get("name", "").strip(), "name": request.form.get("name", "").strip(),
"category": request.form.get("category", "").strip(), "category": request.form.get("category", "").strip(),
"base_type": normalize_base_type(request.form.get("base_type"), form_data.get("base_type", "neutral")), "base_type": normalize_base_type(request.form.get("base_type"), form_data.get("base_type", "neutral")),
"flavor_profile": normalize_food_flavor(request.form.get("flavor_profile"), form_data.get("flavor_profile", "neutral")),
"suggestion_role": normalize_food_role(request.form.get("suggestion_role"), form_data.get("suggestion_role", "base")), "suggestion_role": normalize_food_role(request.form.get("suggestion_role"), form_data.get("suggestion_role", "base")),
"suggestion_priority": normalize_suggestion_priority( "suggestion_priority": normalize_suggestion_priority(
request.form.get("suggestion_priority"), request.form.get("suggestion_priority"),
form_data.get("suggestion_priority", "normal"), form_data.get("suggestion_priority", "normal"),
), ),
"can_be_meal_core": request.form.get("can_be_meal_core", "0") == "1", "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_type": meal_type,
"meal_tags": normalize_meal_tags(request.form.getlist("meal_tags")), "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")), "energy_density": normalize_energy_density(request.form.get("energy_density"), form_data.get("energy_density", "neutral")),
"note": request.form.get("note", "").strip(), "note": request.form.get("note", "").strip(),
@@ -872,13 +904,17 @@ def extract_item_form_data(existing: dict | None = None) -> dict:
"target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT), "target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT),
"food_search": request.form.get("food_search", "").strip(), "food_search": request.form.get("food_search", "").strip(),
"daypart_ids": daypart_ids, "daypart_ids": daypart_ids,
"component_ids": [int(value) for value in request.form.getlist("component_ids") if value.isdigit()], "component_ids": component_ids,
"quick_food_name": request.form.get("quick_food_name", "").strip(), "quick_food_name": request.form.get("quick_food_name", "").strip(),
"quick_food_category": request.form.get("quick_food_category", "").strip(), "quick_food_category": request.form.get("quick_food_category", "").strip(),
"quick_food_base_type": normalize_base_type( "quick_food_base_type": normalize_base_type(
request.form.get("quick_food_base_type"), request.form.get("quick_food_base_type"),
form_data.get("quick_food_base_type", "neutral"), form_data.get("quick_food_base_type", "neutral"),
), ),
"quick_food_flavor_profile": normalize_food_flavor(
request.form.get("quick_food_flavor_profile"),
form_data.get("quick_food_flavor_profile", "neutral"),
),
"quick_food_role": normalize_food_role( "quick_food_role": normalize_food_role(
request.form.get("quick_food_role"), request.form.get("quick_food_role"),
form_data.get("quick_food_role", "base"), form_data.get("quick_food_role", "base"),
@@ -899,9 +935,9 @@ def create_quick_food_from_form(form_data: dict) -> int:
cursor = get_db().execute( cursor = get_db().execute(
""" """
INSERT INTO items ( INSERT INTO items (
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 household_id, owner_user_id, target_user_id, visibility, kind, name, category, base_type, flavor_profile, suggestion_role, suggestion_priority, can_be_meal_core, energy_density, note, created_by, updated_by
) )
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
current_household_id(), current_household_id(),
@@ -911,6 +947,7 @@ def create_quick_food_from_form(form_data: dict) -> int:
form_data["quick_food_name"], form_data["quick_food_name"],
form_data["quick_food_category"], form_data["quick_food_category"],
form_data["quick_food_base_type"], form_data["quick_food_base_type"],
form_data["quick_food_flavor_profile"],
form_data["quick_food_role"], form_data["quick_food_role"],
form_data["quick_food_priority"], form_data["quick_food_priority"],
1 if form_data["quick_food_can_be_meal_core"] else 0, 1 if form_data["quick_food_can_be_meal_core"] else 0,
@@ -1207,6 +1244,35 @@ def sync_meal_components(meal_id: int, food_ids: list[int]) -> None:
) )
def fetch_items_by_ids(item_ids: list[int]) -> list[dict]:
normalized_ids = list(dict.fromkeys(int(item_id) for item_id in item_ids if int(item_id) > 0))
if not normalized_ids:
return []
placeholders = ", ".join("?" for _ in normalized_ids)
rows = get_db().execute(
f"""
SELECT items.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username,
target.display_name AS target_display_name,
target.username AS target_username,
EXISTS(
SELECT 1
FROM shopping_entries
WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0
) AS is_on_shopping_list
FROM items
LEFT JOIN users AS owner ON owner.id = items.owner_user_id
LEFT JOIN users AS target ON target.id = items.target_user_id
WHERE items.id IN ({placeholders}) AND {visible_clause('items')}
""",
[*normalized_ids, *visible_params()],
).fetchall()
items_by_id = {item["id"]: item for item in decorate_items(rows)}
return [items_by_id[item_id] for item_id in normalized_ids if item_id in items_by_id]
def fetch_shopping_entries(): def fetch_shopping_entries():
rows = get_db().execute( rows = get_db().execute(
f""" f"""
@@ -1445,6 +1511,15 @@ def meal_type_for_daypart(daypart_id: int | None) -> str:
return DAYPART_SLUG_TO_MEAL_TYPE.get(daypart["slug"], "snack") return DAYPART_SLUG_TO_MEAL_TYPE.get(daypart["slug"], "snack")
def daypart_ids_for_meal_type(meal_type: str | None) -> list[int]:
normalized_type = normalize_meal_type(meal_type, "snack")
return [
int(daypart["id"])
for daypart in get_dayparts()
if DAYPART_SLUG_TO_MEAL_TYPE.get(daypart["slug"], "snack") == normalized_type
]
def meal_tags_for_generated_meal(daypart_id: int, foods: list[dict]) -> list[str]: def meal_tags_for_generated_meal(daypart_id: int, foods: list[dict]) -> list[str]:
daypart = get_daypart_by_id(daypart_id) daypart = get_daypart_by_id(daypart_id)
slug = daypart["slug"] if daypart else "" slug = daypart["slug"] if daypart else ""
@@ -1522,17 +1597,17 @@ def meal_pattern_definitions(daypart_slug: str) -> list[dict]:
{ {
"reason": "Passt gut zu Frühstück", "reason": "Passt gut zu Frühstück",
"slots": [ "slots": [
{"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True}, {"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True, "flavors": {"sweet", "neutral"}},
{"base_types": {"dairy", "protein"}, "roles": {"base", "main", "complement", "solo", "snack"}, "core_only": False}, {"base_types": {"dairy", "protein"}, "roles": {"base", "main", "complement", "solo", "snack"}, "core_only": False, "flavors": {"sweet", "neutral"}},
{"base_types": {"fruit"}, "roles": {"complement", "topping", "snack", "base"}, "core_only": False}, {"base_types": {"fruit"}, "roles": {"complement", "topping", "snack", "base"}, "core_only": False, "flavors": {"sweet", "neutral"}},
], ],
}, },
{ {
"reason": "Passt gut für Frühstück", "reason": "Passt gut für Frühstück",
"slots": [ "slots": [
{"base_types": {"dairy"}, "roles": {"base", "main", "solo", "snack"}, "core_only": True}, {"base_types": {"dairy"}, "roles": {"base", "main", "solo", "snack"}, "core_only": True, "flavors": {"sweet", "neutral"}},
{"base_types": {"carb"}, "roles": {"base", "main", "complement", "solo"}, "core_only": False}, {"base_types": {"carb"}, "roles": {"base", "main", "complement", "solo"}, "core_only": False, "flavors": {"sweet", "neutral"}},
{"base_types": {"nuts", "seeds", "fruit"}, "roles": {"topping", "complement", "snack"}, "core_only": False}, {"base_types": {"nuts", "seeds", "fruit"}, "roles": {"topping", "complement", "snack"}, "core_only": False, "flavors": {"sweet", "neutral"}},
], ],
}, },
] ]
@@ -1542,22 +1617,22 @@ def meal_pattern_definitions(daypart_slug: str) -> list[dict]:
{ {
"reason": "Passt gut zu einem kleinen Snack", "reason": "Passt gut zu einem kleinen Snack",
"slots": [ "slots": [
{"base_types": {"dairy"}, "roles": {"base", "solo", "snack"}, "core_only": True}, {"base_types": {"dairy"}, "roles": {"base", "solo", "snack"}, "core_only": True, "flavors": {"sweet", "neutral"}},
{"base_types": {"fruit"}, "roles": {"complement", "snack", "topping"}, "core_only": False}, {"base_types": {"fruit"}, "roles": {"complement", "snack", "topping"}, "core_only": False, "flavors": {"sweet", "neutral"}},
], ],
}, },
{ {
"reason": "Zuhause schnell kombinierbar", "reason": "Zuhause schnell kombinierbar",
"slots": [ "slots": [
{"base_types": {"fruit"}, "roles": {"base", "snack", "complement"}, "core_only": True}, {"base_types": {"fruit"}, "roles": {"base", "snack", "complement"}, "core_only": True, "flavors": {"sweet", "neutral"}},
{"base_types": {"nuts", "seeds"}, "roles": {"topping", "snack", "complement"}, "core_only": False}, {"base_types": {"nuts", "seeds"}, "roles": {"topping", "snack", "complement"}, "core_only": False, "flavors": {"sweet", "neutral"}},
], ],
}, },
{ {
"reason": "Passt gut zu einem kleinen Snack", "reason": "Passt gut zu einem kleinen Snack",
"slots": [ "slots": [
{"base_types": {"carb"}, "roles": {"solo", "base", "snack"}, "core_only": True}, {"base_types": {"carb"}, "roles": {"solo", "base", "snack"}, "core_only": True, "flavors": {"sweet", "neutral"}},
{"base_types": {"protein", "dairy"}, "roles": {"complement", "snack", "base"}, "core_only": False}, {"base_types": {"protein", "dairy"}, "roles": {"complement", "snack", "base"}, "core_only": False, "flavors": {"sweet", "neutral"}},
], ],
}, },
] ]
@@ -1566,24 +1641,24 @@ def meal_pattern_definitions(daypart_slug: str) -> list[dict]:
{ {
"reason": "Zuhause als vollständige Mahlzeit möglich", "reason": "Zuhause als vollständige Mahlzeit möglich",
"slots": [ "slots": [
{"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True}, {"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}},
{"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True}, {"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}},
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False}, {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}},
], ],
}, },
{ {
"reason": "Lässt sich gut ergänzen", "reason": "Lässt sich gut ergänzen",
"slots": [ "slots": [
{"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True}, {"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}},
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False}, {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}},
{"base_types": {"carb"}, "roles": {"base", "complement", "solo"}, "core_only": False}, {"base_types": {"carb"}, "roles": {"base", "complement", "solo"}, "core_only": False, "flavors": {"savory", "neutral"}},
], ],
}, },
{ {
"reason": "Schnell und alltagstauglich", "reason": "Schnell und alltagstauglich",
"slots": [ "slots": [
{"base_types": {"carb", "protein"}, "roles": {"solo"}, "core_only": True}, {"base_types": {"carb", "protein"}, "roles": {"solo"}, "core_only": True, "flavors": {"savory", "neutral"}},
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False}, {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}},
], ],
}, },
] ]
@@ -1629,6 +1704,8 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
continue continue
seen_signatures.add(signature) seen_signatures.add(signature)
combo_items = list(combo) combo_items = list(combo)
if not components_are_flavor_compatible(combo_items):
continue
suggestions.append( suggestions.append(
{ {
"title": build_generated_meal_name(combo_items, daypart_slug), "title": build_generated_meal_name(combo_items, daypart_slug),
@@ -1685,6 +1762,8 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4)
continue continue
component_items = [visible_food_map[component_id] for component_id in component_ids] component_items = [visible_food_map[component_id] for component_id in component_ids]
if not components_are_flavor_compatible(component_items):
continue
available_items = [home_food_map[component_id] for component_id in component_ids if component_id in home_food_map] available_items = [home_food_map[component_id] for component_id in component_ids if component_id in home_food_map]
missing_items = [visible_food_map[component_id] for component_id in component_ids if component_id not in home_food_ids] missing_items = [visible_food_map[component_id] for component_id in component_ids if component_id not in home_food_ids]
@@ -2846,11 +2925,14 @@ def render_item_form(kind: str, *, item: dict | None, form_data: dict):
item=item, item=item,
dayparts=get_dayparts(), dayparts=get_dayparts(),
food_groups=group_items_by_availability(foods), food_groups=group_items_by_availability(foods),
selected_components=fetch_items_by_ids(form_data.get("component_ids", [])) if kind == "meal" else [],
categories=get_category_options( categories=get_category_options(
form_data.get("category") or form_data.get("quick_food_category") form_data.get("category") or form_data.get("quick_food_category")
), ),
form_data=form_data, form_data=form_data,
builder_options=[(key, label) for key, label in BUILDER_LABELS.items()], builder_options=[(key, label) for key, label in BUILDER_LABELS.items()],
food_flavor_options=FOOD_FLAVOR_OPTIONS,
food_flavor_descriptions=FOOD_FLAVOR_DESCRIPTIONS,
food_role_options=FOOD_ROLE_OPTIONS, food_role_options=FOOD_ROLE_OPTIONS,
food_role_descriptions=FOOD_ROLE_DESCRIPTIONS, food_role_descriptions=FOOD_ROLE_DESCRIPTIONS,
suggestion_priority_options=SUGGESTION_PRIORITY_OPTIONS, suggestion_priority_options=SUGGESTION_PRIORITY_OPTIONS,
@@ -3684,6 +3766,7 @@ def item_create(kind: str):
"name": request.args.get("name", "").strip(), "name": request.args.get("name", "").strip(),
"category": "", "category": "",
"base_type": "neutral", "base_type": "neutral",
"flavor_profile": "neutral",
"suggestion_role": "base", "suggestion_role": "base",
"suggestion_priority": "normal", "suggestion_priority": "normal",
"can_be_meal_core": False, "can_be_meal_core": False,
@@ -3700,6 +3783,7 @@ def item_create(kind: str):
"quick_food_name": "", "quick_food_name": "",
"quick_food_category": "", "quick_food_category": "",
"quick_food_base_type": "neutral", "quick_food_base_type": "neutral",
"quick_food_flavor_profile": "neutral",
"quick_food_role": "base", "quick_food_role": "base",
"quick_food_priority": "normal", "quick_food_priority": "normal",
"quick_food_can_be_meal_core": False, "quick_food_can_be_meal_core": False,
@@ -3709,7 +3793,16 @@ def item_create(kind: str):
if request.method == "POST": if request.method == "POST":
form_action = request.form.get("form_action", "save_item") form_action = request.form.get("form_action", "save_item")
form_data = extract_item_form_data(form_data) form_data = extract_item_form_data(kind, form_data)
if kind == "meal" and request.form.get("remove_component_id", "").isdigit():
remove_component_id = int(request.form.get("remove_component_id", "0"))
form_data["component_ids"] = [
component_id
for component_id in form_data["component_ids"]
if component_id != remove_component_id
]
return render_item_form(kind, item=None, form_data=form_data)
if kind == "meal" and form_action == "filter_foods": if kind == "meal" and form_action == "filter_foods":
return render_item_form(kind, item=None, form_data=form_data) return render_item_form(kind, item=None, form_data=form_data)
@@ -3742,9 +3835,9 @@ def item_create(kind: str):
cursor = get_db().execute( cursor = get_db().execute(
""" """
INSERT INTO items ( INSERT INTO items (
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 household_id, owner_user_id, target_user_id, visibility, kind, name, category, base_type, flavor_profile, 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(), current_household_id(),
@@ -3755,6 +3848,7 @@ def item_create(kind: str):
form_data["name"], form_data["name"],
form_data["category"] if kind == "food" else None, form_data["category"] if kind == "food" else None,
form_data["base_type"] if kind == "food" else "neutral", form_data["base_type"] if kind == "food" else "neutral",
form_data["flavor_profile"] if kind == "food" else "neutral",
form_data["suggestion_role"] if kind == "food" else "base", form_data["suggestion_role"] if kind == "food" else "base",
form_data["suggestion_priority"] if kind == "food" else "normal", form_data["suggestion_priority"] if kind == "food" else "normal",
1 if (form_data["can_be_meal_core"] if kind == "food" else False) else 0, 1 if (form_data["can_be_meal_core"] if kind == "food" else False) else 0,
@@ -3793,11 +3887,12 @@ def item_edit(item_id: int):
"name": item["name"], "name": item["name"],
"category": item["category"] or "", "category": item["category"] or "",
"base_type": item.get("base_type") or "neutral", "base_type": item.get("base_type") or "neutral",
"flavor_profile": item.get("flavor_profile") or "neutral",
"suggestion_role": item.get("suggestion_role") or "base", "suggestion_role": item.get("suggestion_role") or "base",
"suggestion_priority": item.get("suggestion_priority") or "normal", "suggestion_priority": item.get("suggestion_priority") or "normal",
"can_be_meal_core": bool(item.get("can_be_meal_core")), "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_type": item.get("meal_type") or meal_type_for_daypart(item.get("primary_daypart_id")),
"meal_tags": decode_tag_list(item.get("meal_tags")), "meal_tags": normalize_meal_tags(item.get("meal_tags")),
"energy_density": item.get("energy_density") or "neutral", "energy_density": item.get("energy_density") or "neutral",
"note": item["note"] or "", "note": item["note"] or "",
"visibility": item["visibility"], "visibility": item["visibility"],
@@ -3809,6 +3904,7 @@ def item_edit(item_id: int):
"quick_food_name": "", "quick_food_name": "",
"quick_food_category": "", "quick_food_category": "",
"quick_food_base_type": "neutral", "quick_food_base_type": "neutral",
"quick_food_flavor_profile": "neutral",
"quick_food_role": "base", "quick_food_role": "base",
"quick_food_priority": "normal", "quick_food_priority": "normal",
"quick_food_can_be_meal_core": False, "quick_food_can_be_meal_core": False,
@@ -3818,7 +3914,16 @@ def item_edit(item_id: int):
if request.method == "POST": if request.method == "POST":
form_action = request.form.get("form_action", "save_item") form_action = request.form.get("form_action", "save_item")
form_data = extract_item_form_data(form_data) form_data = extract_item_form_data(item["kind"], form_data)
if item["kind"] == "meal" and request.form.get("remove_component_id", "").isdigit():
remove_component_id = int(request.form.get("remove_component_id", "0"))
form_data["component_ids"] = [
component_id
for component_id in form_data["component_ids"]
if component_id != remove_component_id
]
return render_item_form(item["kind"], item=item, form_data=form_data)
if item["kind"] == "meal" and form_action == "filter_foods": if item["kind"] == "meal" and form_action == "filter_foods":
return render_item_form(item["kind"], item=item, form_data=form_data) return render_item_form(item["kind"], item=item, form_data=form_data)
@@ -3854,6 +3959,7 @@ def item_edit(item_id: int):
SET name = ?, SET name = ?,
category = ?, category = ?,
base_type = ?, base_type = ?,
flavor_profile = ?,
suggestion_role = ?, suggestion_role = ?,
suggestion_priority = ?, suggestion_priority = ?,
can_be_meal_core = ?, can_be_meal_core = ?,
@@ -3872,6 +3978,7 @@ def item_edit(item_id: int):
form_data["name"], form_data["name"],
form_data["category"] if item["kind"] == "food" else None, form_data["category"] if item["kind"] == "food" else None,
form_data["base_type"] if item["kind"] == "food" else "neutral", form_data["base_type"] if item["kind"] == "food" else "neutral",
form_data["flavor_profile"] if item["kind"] == "food" else "neutral",
form_data["suggestion_role"] if item["kind"] == "food" else "base", form_data["suggestion_role"] if item["kind"] == "food" else "base",
form_data["suggestion_priority"] if item["kind"] == "food" else "normal", 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, 1 if (form_data["can_be_meal_core"] if item["kind"] == "food" else False) else 0,
+1
View File
@@ -122,6 +122,7 @@ CREATE TABLE IF NOT EXISTS items (
name TEXT NOT NULL, name TEXT NOT NULL,
category TEXT, category TEXT,
base_type TEXT NOT NULL DEFAULT 'neutral', base_type TEXT NOT NULL DEFAULT 'neutral',
flavor_profile TEXT NOT NULL DEFAULT 'neutral',
suggestion_role TEXT NOT NULL DEFAULT 'base', suggestion_role TEXT NOT NULL DEFAULT 'base',
suggestion_priority TEXT NOT NULL DEFAULT 'normal', suggestion_priority TEXT NOT NULL DEFAULT 'normal',
can_be_meal_core INTEGER NOT NULL DEFAULT 0, can_be_meal_core INTEGER NOT NULL DEFAULT 0,
+52
View File
@@ -653,6 +653,58 @@ h3 {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
} }
.selected-component-stack {
display: grid;
gap: 0.7rem;
margin: 0 0 1rem;
}
.selected-components-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
}
.selected-component-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.85rem;
padding: 0.9rem 1rem;
border-radius: 18px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--accent-soft) 18%, var(--surface-strong) 82%);
}
.selected-component-main {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.selected-component-main strong,
.selected-component-main small {
min-width: 0;
overflow-wrap: anywhere;
}
.selected-component-main small {
color: var(--muted);
}
.selected-component-remove {
white-space: nowrap;
}
[data-theme="dark"] .selected-component-card {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--surface-soft) 82%, #4d413d 18%),
color-mix(in srgb, var(--surface) 96%, #2c2523 4%)
);
border-color: color-mix(in srgb, rgba(243, 177, 125, 0.36) 48%, var(--line) 52%);
}
.quick-food-grid { .quick-food-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
+50 -1
View File
@@ -4,7 +4,15 @@
<section class="page-intro"> <section class="page-intro">
<div> <div>
<p class="eyebrow">{{ item_kind_labels[kind] }}</p> <p class="eyebrow">{{ item_kind_labels[kind] }}</p>
<h1>{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1> <h1>
{% if item and kind == 'meal' %}
{{ item.name }}
{% elif item %}
{{ item.name }} bearbeiten
{% else %}
Neue {{ item_kind_singular_labels[kind] }}
{% endif %}
</h1>
<p class="lead"> <p class="lead">
{% if kind == 'food' %} {% if kind == 'food' %}
Name, Sichtbarkeit und ein paar ruhige Hinweise dazu, wie ein Lebensmittel in Vorschlägen gut passt. Name, Sichtbarkeit und ein paar ruhige Hinweise dazu, wie ein Lebensmittel in Vorschlägen gut passt.
@@ -52,6 +60,7 @@
</div> </div>
{% if kind == 'food' %} {% if kind == 'food' %}
<div class="dual-grid">
<label> <label>
Baustein Baustein
<select name="base_type"> <select name="base_type">
@@ -62,6 +71,17 @@
<small class="helper-text">{{ builder_descriptions[form_data.base_type] }}</small> <small class="helper-text">{{ builder_descriptions[form_data.base_type] }}</small>
</label> </label>
<label>
Geschmacksrichtung
<select name="flavor_profile">
{% for value, label in food_flavor_options %}
<option value="{{ value }}" {% if form_data.flavor_profile == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">{{ food_flavor_descriptions[form_data.flavor_profile] }}</small>
</label>
</div>
<div class="dual-grid"> <div class="dual-grid">
<label> <label>
Rolle in Vorschlägen Rolle in Vorschlägen
@@ -162,6 +182,7 @@
</div> </div>
{% endif %} {% endif %}
{% if kind == 'food' %}
<fieldset> <fieldset>
<legend>Passende Tageszeiten</legend> <legend>Passende Tageszeiten</legend>
<div class="checkbox-grid daypart-option-grid"> <div class="checkbox-grid daypart-option-grid">
@@ -178,11 +199,31 @@
{% endfor %} {% endfor %}
</div> </div>
</fieldset> </fieldset>
{% endif %}
{% if kind == 'meal' %} {% if kind == 'meal' %}
<fieldset> <fieldset>
<legend>Bestandteile der Mahlzeitenidee</legend> <legend>Bestandteile der Mahlzeitenidee</legend>
<p class="muted">Du kannst eine Mahlzeitenidee frei benennen oder aus sichtbaren Lebensmitteln zusammensetzen. Nouri nutzt dabei später Grundtyp, Rolle und Tageszeit der Lebensmittel für ruhigere Vorschläge.</p> <p class="muted">Du kannst eine Mahlzeitenidee frei benennen oder aus sichtbaren Lebensmitteln zusammensetzen. Nouri nutzt dabei später Grundtyp, Rolle und Tageszeit der Lebensmittel für ruhigere Vorschläge.</p>
{% if selected_components %}
<div class="selected-component-stack">
<p class="helper-text">Schon ausgewählt</p>
<div class="selected-components-grid">
{% for component in selected_components %}
<article class="selected-component-card">
<input type="hidden" name="component_ids" value="{{ component.id }}">
<div class="selected-component-main">
<strong>{{ component.name }}</strong>
<small>{{ component.base_type_label }} · {{ component.visibility_label }}</small>
</div>
<button class="ghost-button selected-component-remove" type="submit" name="remove_component_id" value="{{ component.id }}">
Entfernen
</button>
</article>
{% endfor %}
</div>
</div>
{% endif %}
<div class="inline-form"> <div class="inline-form">
<label class="wide"> <label class="wide">
Lebensmittel suchen Lebensmittel suchen
@@ -239,6 +280,14 @@
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label>
Geschmacksrichtung
<select name="quick_food_flavor_profile">
{% for value, label in food_flavor_options %}
<option value="{{ value }}" {% if form_data.quick_food_flavor_profile == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label> <label>
Rolle in Vorschlägen Rolle in Vorschlägen
<select name="quick_food_role"> <select name="quick_food_role">