Compare commits
3 Commits
V1.2.2
...
c5dea16c53
| Author | SHA1 | Date | |
|---|---|---|---|
| c5dea16c53 | |||
| e057cf0382 | |||
| 5a1c1d5c41 |
@@ -67,6 +67,24 @@ BUILDER_DESCRIPTIONS = {
|
||||
|
||||
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 = {
|
||||
"main": "Hauptbaustein",
|
||||
"base": "Basis",
|
||||
|
||||
+73
@@ -39,6 +39,39 @@ def normalize_name_for_profile(name: str | None) -> str:
|
||||
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]:
|
||||
normalized_name = normalize_name_for_profile(name)
|
||||
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:
|
||||
if "db" not in g:
|
||||
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", "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", "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_priority TEXT NOT NULL DEFAULT 'normal'")
|
||||
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, "items", "target_user_id INTEGER")
|
||||
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_priority TEXT NOT NULL DEFAULT 'normal'")
|
||||
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)
|
||||
migrate_item_profiles(database)
|
||||
migrate_food_flavor_profiles(database)
|
||||
database.execute(
|
||||
"""
|
||||
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 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_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")
|
||||
|
||||
+139
-32
@@ -33,6 +33,9 @@ from .constants import (
|
||||
DEFAULT_CATEGORIES,
|
||||
ENERGY_DENSITY_LABELS,
|
||||
ENERGY_DENSITY_OPTIONS,
|
||||
FOOD_FLAVOR_DESCRIPTIONS,
|
||||
FOOD_FLAVOR_LABELS,
|
||||
FOOD_FLAVOR_OPTIONS,
|
||||
FOOD_ROLE_DESCRIPTIONS,
|
||||
FOOD_ROLE_LABELS,
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
allowed = {value for value, _label in FOOD_ROLE_OPTIONS}
|
||||
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["base_type"] = normalize_base_type(entry.get("base_type"), "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_label"] = FOOD_ROLE_LABELS.get(entry["suggestion_role"], FOOD_ROLE_LABELS["base"])
|
||||
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")
|
||||
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 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:
|
||||
if not components_are_flavor_compatible(component_items):
|
||||
return -999
|
||||
|
||||
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]
|
||||
@@ -848,22 +875,27 @@ def group_items_by_availability(items: list[dict]) -> list[dict]:
|
||||
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 {}
|
||||
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 = 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(
|
||||
{
|
||||
"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")),
|
||||
"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_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_type": meal_type,
|
||||
"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(),
|
||||
@@ -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),
|
||||
"food_search": request.form.get("food_search", "").strip(),
|
||||
"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_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_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(
|
||||
request.form.get("quick_food_role"),
|
||||
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(
|
||||
"""
|
||||
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(),
|
||||
@@ -911,6 +947,7 @@ def create_quick_food_from_form(form_data: dict) -> int:
|
||||
form_data["quick_food_name"],
|
||||
form_data["quick_food_category"],
|
||||
form_data["quick_food_base_type"],
|
||||
form_data["quick_food_flavor_profile"],
|
||||
form_data["quick_food_role"],
|
||||
form_data["quick_food_priority"],
|
||||
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():
|
||||
rows = get_db().execute(
|
||||
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")
|
||||
|
||||
|
||||
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]:
|
||||
daypart = get_daypart_by_id(daypart_id)
|
||||
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",
|
||||
"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},
|
||||
{"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, "flavors": {"sweet", "neutral"}},
|
||||
{"base_types": {"fruit"}, "roles": {"complement", "topping", "snack", "base"}, "core_only": False, "flavors": {"sweet", "neutral"}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"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},
|
||||
{"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, "flavors": {"sweet", "neutral"}},
|
||||
{"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",
|
||||
"slots": [
|
||||
{"base_types": {"dairy"}, "roles": {"base", "solo", "snack"}, "core_only": True},
|
||||
{"base_types": {"fruit"}, "roles": {"complement", "snack", "topping"}, "core_only": False},
|
||||
{"base_types": {"dairy"}, "roles": {"base", "solo", "snack"}, "core_only": True, "flavors": {"sweet", "neutral"}},
|
||||
{"base_types": {"fruit"}, "roles": {"complement", "snack", "topping"}, "core_only": False, "flavors": {"sweet", "neutral"}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"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},
|
||||
{"base_types": {"fruit"}, "roles": {"base", "snack", "complement"}, "core_only": True, "flavors": {"sweet", "neutral"}},
|
||||
{"base_types": {"nuts", "seeds"}, "roles": {"topping", "snack", "complement"}, "core_only": False, "flavors": {"sweet", "neutral"}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"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},
|
||||
{"base_types": {"carb"}, "roles": {"solo", "base", "snack"}, "core_only": True, "flavors": {"sweet", "neutral"}},
|
||||
{"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",
|
||||
"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},
|
||||
{"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}},
|
||||
{"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}},
|
||||
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"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},
|
||||
{"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}},
|
||||
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}},
|
||||
{"base_types": {"carb"}, "roles": {"base", "complement", "solo"}, "core_only": False, "flavors": {"savory", "neutral"}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"reason": "Schnell und alltagstauglich",
|
||||
"slots": [
|
||||
{"base_types": {"carb", "protein"}, "roles": {"solo"}, "core_only": True},
|
||||
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False},
|
||||
{"base_types": {"carb", "protein"}, "roles": {"solo"}, "core_only": True, "flavors": {"savory", "neutral"}},
|
||||
{"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
|
||||
seen_signatures.add(signature)
|
||||
combo_items = list(combo)
|
||||
if not components_are_flavor_compatible(combo_items):
|
||||
continue
|
||||
suggestions.append(
|
||||
{
|
||||
"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
|
||||
|
||||
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]
|
||||
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,
|
||||
dayparts=get_dayparts(),
|
||||
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(
|
||||
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_flavor_options=FOOD_FLAVOR_OPTIONS,
|
||||
food_flavor_descriptions=FOOD_FLAVOR_DESCRIPTIONS,
|
||||
food_role_options=FOOD_ROLE_OPTIONS,
|
||||
food_role_descriptions=FOOD_ROLE_DESCRIPTIONS,
|
||||
suggestion_priority_options=SUGGESTION_PRIORITY_OPTIONS,
|
||||
@@ -3684,6 +3766,7 @@ def item_create(kind: str):
|
||||
"name": request.args.get("name", "").strip(),
|
||||
"category": "",
|
||||
"base_type": "neutral",
|
||||
"flavor_profile": "neutral",
|
||||
"suggestion_role": "base",
|
||||
"suggestion_priority": "normal",
|
||||
"can_be_meal_core": False,
|
||||
@@ -3700,6 +3783,7 @@ def item_create(kind: str):
|
||||
"quick_food_name": "",
|
||||
"quick_food_category": "",
|
||||
"quick_food_base_type": "neutral",
|
||||
"quick_food_flavor_profile": "neutral",
|
||||
"quick_food_role": "base",
|
||||
"quick_food_priority": "normal",
|
||||
"quick_food_can_be_meal_core": False,
|
||||
@@ -3709,7 +3793,16 @@ def item_create(kind: str):
|
||||
|
||||
if request.method == "POST":
|
||||
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":
|
||||
return render_item_form(kind, item=None, form_data=form_data)
|
||||
@@ -3742,9 +3835,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, 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(),
|
||||
@@ -3755,6 +3848,7 @@ def item_create(kind: str):
|
||||
form_data["name"],
|
||||
form_data["category"] if kind == "food" else None,
|
||||
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_priority"] if kind == "food" else "normal",
|
||||
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"],
|
||||
"category": item["category"] or "",
|
||||
"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_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")),
|
||||
"meal_tags": normalize_meal_tags(item.get("meal_tags")),
|
||||
"energy_density": item.get("energy_density") or "neutral",
|
||||
"note": item["note"] or "",
|
||||
"visibility": item["visibility"],
|
||||
@@ -3809,6 +3904,7 @@ def item_edit(item_id: int):
|
||||
"quick_food_name": "",
|
||||
"quick_food_category": "",
|
||||
"quick_food_base_type": "neutral",
|
||||
"quick_food_flavor_profile": "neutral",
|
||||
"quick_food_role": "base",
|
||||
"quick_food_priority": "normal",
|
||||
"quick_food_can_be_meal_core": False,
|
||||
@@ -3818,7 +3914,16 @@ def item_edit(item_id: int):
|
||||
|
||||
if request.method == "POST":
|
||||
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":
|
||||
return render_item_form(item["kind"], item=item, form_data=form_data)
|
||||
@@ -3854,6 +3959,7 @@ def item_edit(item_id: int):
|
||||
SET name = ?,
|
||||
category = ?,
|
||||
base_type = ?,
|
||||
flavor_profile = ?,
|
||||
suggestion_role = ?,
|
||||
suggestion_priority = ?,
|
||||
can_be_meal_core = ?,
|
||||
@@ -3872,6 +3978,7 @@ def item_edit(item_id: int):
|
||||
form_data["name"],
|
||||
form_data["category"] if item["kind"] == "food" else None,
|
||||
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_priority"] if item["kind"] == "food" else "normal",
|
||||
1 if (form_data["can_be_meal_core"] if item["kind"] == "food" else False) else 0,
|
||||
|
||||
@@ -122,6 +122,7 @@ CREATE TABLE IF NOT EXISTS items (
|
||||
name TEXT NOT NULL,
|
||||
category TEXT,
|
||||
base_type TEXT NOT NULL DEFAULT 'neutral',
|
||||
flavor_profile TEXT NOT NULL DEFAULT 'neutral',
|
||||
suggestion_role TEXT NOT NULL DEFAULT 'base',
|
||||
suggestion_priority TEXT NOT NULL DEFAULT 'normal',
|
||||
can_be_meal_core INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
@@ -653,6 +653,58 @@ h3 {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<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">
|
||||
{% if kind == 'food' %}
|
||||
Name, Sichtbarkeit und ein paar ruhige Hinweise dazu, wie ein Lebensmittel in Vorschlägen gut passt.
|
||||
@@ -52,15 +60,27 @@
|
||||
</div>
|
||||
|
||||
{% if kind == 'food' %}
|
||||
<label>
|
||||
Baustein
|
||||
<select name="base_type">
|
||||
{% for value, label in builder_options %}
|
||||
<option value="{{ value }}" {% if form_data.base_type == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="helper-text">{{ builder_descriptions[form_data.base_type] }}</small>
|
||||
</label>
|
||||
<div class="dual-grid">
|
||||
<label>
|
||||
Baustein
|
||||
<select name="base_type">
|
||||
{% for value, label in builder_options %}
|
||||
<option value="{{ value }}" {% if form_data.base_type == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="helper-text">{{ builder_descriptions[form_data.base_type] }}</small>
|
||||
</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">
|
||||
<label>
|
||||
@@ -162,27 +182,48 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<fieldset>
|
||||
<legend>Passende Tageszeiten</legend>
|
||||
<div class="checkbox-grid daypart-option-grid">
|
||||
{% for daypart in dayparts %}
|
||||
<label class="daypart-option">
|
||||
<input type="checkbox" name="daypart_ids" value="{{ daypart.id }}" {% if daypart.id in form_data.daypart_ids %}checked{% endif %}>
|
||||
<span class="daypart-option-card">
|
||||
<span class="daypart-option-icon">
|
||||
<span class="ui-icon {{ daypart_icon_class(daypart.slug) }}"></span>
|
||||
{% if kind == 'food' %}
|
||||
<fieldset>
|
||||
<legend>Passende Tageszeiten</legend>
|
||||
<div class="checkbox-grid daypart-option-grid">
|
||||
{% for daypart in dayparts %}
|
||||
<label class="daypart-option">
|
||||
<input type="checkbox" name="daypart_ids" value="{{ daypart.id }}" {% if daypart.id in form_data.daypart_ids %}checked{% endif %}>
|
||||
<span class="daypart-option-card">
|
||||
<span class="daypart-option-icon">
|
||||
<span class="ui-icon {{ daypart_icon_class(daypart.slug) }}"></span>
|
||||
</span>
|
||||
<span class="daypart-option-label">{{ daypart.name }}</span>
|
||||
</span>
|
||||
<span class="daypart-option-label">{{ daypart.name }}</span>
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
|
||||
{% if kind == 'meal' %}
|
||||
<fieldset>
|
||||
<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>
|
||||
{% 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">
|
||||
<label class="wide">
|
||||
Lebensmittel suchen
|
||||
@@ -239,6 +280,14 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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>
|
||||
Rolle in Vorschlägen
|
||||
<select name="quick_food_role">
|
||||
|
||||
Reference in New Issue
Block a user